Repository: pantsbuild/pants

Issue:
Add exact requirements testing for JVM tools.
Currently, JVM user lockfiles need to test for subsets of locked requirements, but tools need to test for an exact match.


Discussion:

Resolving patch:
diff --git a/src/python/pants/jvm/resolve/coursier_fetch.py b/src/python/pants/jvm/resolve/coursier_fetch.py
--- a/src/python/pants/jvm/resolve/coursier_fetch.py
+++ b/src/python/pants/jvm/resolve/coursier_fetch.py
@@ -51,7 +51,7 @@
 )
 from pants.jvm.resolve.coursier_setup import Coursier, CoursierWrapperProcess
 from pants.jvm.resolve.key import CoursierResolveKey
-from pants.jvm.resolve.lockfile_metadata import JVMLockfileMetadata
+from pants.jvm.resolve.lockfile_metadata import JVMLockfileMetadata, LockfileContext
 from pants.jvm.subsystems import JvmSubsystem
 from pants.jvm.target_types import JvmArtifactFieldSet, JvmArtifactJarSourceField, JvmArtifactTarget
 from pants.jvm.util_rules import ExtractFileDigest
@@ -449,7 +449,9 @@ async def fetch_with_coursier(request: CoursierFetchRequest) -> FallibleClasspat
 
     requirement = ArtifactRequirement.from_jvm_artifact_target(request.component.representative)
 
-    if lockfile.metadata and not lockfile.metadata.is_valid_for([requirement]):
+    if lockfile.metadata and not lockfile.metadata.is_valid_for(
+        [requirement], LockfileContext.USER
+    ):
         raise ValueError(
             f"Requirement `{requirement.to_coord_arg_str()}` has changed since the lockfile "
             f"for {request.resolve.path} was generated. Run `./pants generate-lockfiles` to update your "
@@ -739,7 +741,9 @@ async def materialize_classpath_for_tool(request: ToolClasspathRequest) -> ToolC
                 lockfile_req.artifact_inputs, lockfile_req.artifact_option_name
             ),
         )
-        if resolution.metadata and not resolution.metadata.is_valid_for(lockfile_inputs):
+        if resolution.metadata and not resolution.metadata.is_valid_for(
+            lockfile_inputs, LockfileContext.TOOL
+        ):
             raise ValueError(
                 f"The lockfile {lockfile_req.lockfile_dest} (configured by the option "
                 f"{lockfile_req.lockfile_option_name}) was generated with different requirements "
diff --git a/src/python/pants/jvm/resolve/lockfile_metadata.py b/src/python/pants/jvm/resolve/lockfile_metadata.py
--- a/src/python/pants/jvm/resolve/lockfile_metadata.py
+++ b/src/python/pants/jvm/resolve/lockfile_metadata.py
@@ -24,6 +24,11 @@ class InvalidJVMLockfileReason(Enum):
     REQUIREMENTS_MISMATCH = "requirements_mismatch"
 
 
+class LockfileContext(Enum):
+    USER = "user"
+    TOOL = "tool"
+
+
 @dataclass(frozen=True)
 class JVMLockfileMetadata(LockfileMetadata):
 
@@ -57,6 +62,7 @@ def from_lockfile(
     def is_valid_for(
         self,
         requirements: Iterable[ArtifactRequirement] | None,
+        context: LockfileContext,
     ) -> LockfileMetadataValidation:
         """Returns Truthy if this `JVMLockfileMetadata` can be used in the current execution
         context."""
@@ -67,10 +73,13 @@ def is_valid_for(
 @_jvm_lockfile_metadata(1)
 @dataclass(frozen=True)
 class JVMLockfileMetadataV1(JVMLockfileMetadata):
-    """Lockfile version that permits specifying a requirements as a set rather than a digest.
+    """Initial metadata version for JVM user lockfiles.
+
+    User validity is tested by the set of user requirements strings appearing as a subset of those
+    in the metadata requirements.
 
-    Validity is tested by the set of requirements strings being the same in the user requirements as
-    those in the stored requirements.
+    Tool validity is tested by the set of user requirements strings being an exact match of those
+    in the metadata requirements.
     """
 
     requirements: FrozenOrderedSet[str]
@@ -79,7 +88,7 @@ class JVMLockfileMetadataV1(JVMLockfileMetadata):
     def from_artifact_requirements(
         cls, requirements: Iterable[ArtifactRequirement]
     ) -> JVMLockfileMetadataV1:
-        return cls(FrozenOrderedSet(i.to_metadata_str() for i in requirements))
+        return cls(FrozenOrderedSet(sorted(i.to_metadata_str() for i in requirements)))
 
     @classmethod
     def _from_json_dict(
@@ -110,16 +119,16 @@ def additional_header_attrs(cls, instance: LockfileMetadata) -> dict[Any, Any]:
     def is_valid_for(
         self,
         requirements: Iterable[ArtifactRequirement] | None,
+        context: LockfileContext,
     ) -> LockfileMetadataValidation:
-        """Returns a truthy object if the request requirements match the metadata requirements.
-
-        For this version, "match" is defined as the request requirements being a non-strict subset
-        of the metadata requirements.
-        """
+        """Returns a truthy object if the request requirements match the metadata requirements."""
 
         failure_reasons: set[InvalidJVMLockfileReason] = set()
+        req_strings = FrozenOrderedSet(sorted(i.to_metadata_str() for i in requirements or []))
 
-        if not self.requirements.issuperset(i.to_metadata_str() for i in requirements or []):
+        if (context == LockfileContext.USER and not self.requirements.issuperset(req_strings)) or (
+            context == LockfileContext.TOOL and self.requirements != req_strings
+        ):
             failure_reasons.add(InvalidJVMLockfileReason.REQUIREMENTS_MISMATCH)
 
         return LockfileMetadataValidation(failure_reasons)


Test patch: