Repository: pantsbuild/pants

Issue:
Target parametrization should default the values of parameters during address expansion
As alluded to in the [Resolution section of the design doc](https://docs.google.com/document/d/1mzZWnXiE6OkgMH_Dm7WeA3ck9veusQmr_c6GWPb_tsQ/edit#heading=h.b9cow77oy8vk), un-parametrized target addresses for "static" parametrization should be expanded to the relevant parametrization when used in the `dependencies` field of targets.

This is important because it allows a dependee target which is itself parametrized to use an un-parametrized address to refer to an explicitly declared dependency. Otherwise, the dependencies field _itself_ would need to be parametrized in order to be valid. For example:
```python
scala_sources(resolve=parametrize("a", "b"))

deploy_jar(
    ...,
    resolve="a",
    dependencies=[
        ":app@resolve=a",
    ],
)

deploy_jar(
    ...,
    resolve="b",
    dependencies=[
        ":app@resolve=b",
    ],
)
```
... could become:
```python
scala_sources(resolve=parametrize("a", "b"))

deploy_jar(
    ...,
    resolve=parametrize("a", "b"),
    dependencies=[
        # Newly legal, because we defaulted parametrization of addresses.
        ":app",
    ],
)
```


Discussion:
How does the parametrization of addresses know what to include in the address for the dependency?
Example:
```python
scala_sources(resolve=parametrize("a", "b"))

deploy_jar(
    ...,
    unrelated=parametrize("c", "d"),
    resolve=parametrize("a", "b"),
    dependencies=[
        # Newly legal, because we defaulted parametrization of addresses. Does it also include `@unrelated` or not for `:app`.. and why so?
        ":app",
    ],
)
```

> How does the parametrization of addresses know what to include in the address for the dependency?

Good question! I think that resolving explicitly specified dependencies might actually involve needing to compare field values (iff both the consuming target and the consumed target have been parametrized). But we'll see. Starting this one now.

Resolving patch:
diff --git a/src/python/pants/build_graph/address.py b/src/python/pants/build_graph/address.py
--- a/src/python/pants/build_graph/address.py
+++ b/src/python/pants/build_graph/address.py
@@ -318,6 +318,12 @@ def is_default_target(self) -> bool:
     def is_parametrized(self) -> bool:
         return bool(self.parameters)
 
+    def is_parametrized_subset_of(self, other: Address) -> bool:
+        """True if this Address is == to the given Address, but with a subset of its parameters."""
+        if not self._equal_without_parameters(other):
+            return False
+        return self.parameters.items() <= other.parameters.items()
+
     @property
     def filename(self) -> str:
         if self._relative_file_path is None:
@@ -439,17 +445,19 @@ def create_file(self, relative_file_path: str) -> Address:
             relative_file_path=relative_file_path,
         )
 
-    def __eq__(self, other):
-        if not isinstance(other, Address):
-            return False
+    def _equal_without_parameters(self, other: Address) -> bool:
         return (
             self.spec_path == other.spec_path
             and self._target_name == other._target_name
-            and self.parameters == other.parameters
             and self.generated_name == other.generated_name
             and self._relative_file_path == other._relative_file_path
         )
 
+    def __eq__(self, other):
+        if not isinstance(other, Address):
+            return False
+        return self._equal_without_parameters(other) and self.parameters == other.parameters
+
     def __hash__(self):
         return self._hash
 
diff --git a/src/python/pants/engine/internals/graph.py b/src/python/pants/engine/internals/graph.py
--- a/src/python/pants/engine/internals/graph.py
+++ b/src/python/pants/engine/internals/graph.py
@@ -257,7 +257,7 @@ async def resolve_target_parametrizations(
     else:
         first, *rest = Parametrize.expand(address, target_adaptor.kwargs)
         if rest:
-            # The target was parametrized, and so the original target is not addressable.
+            # The target was parametrized, and so the original Target does not exist.
             generated = FrozenDict(
                 (
                     parameterized_address,
@@ -1083,7 +1083,23 @@ async def resolve_dependencies(
         parametrizations = await Get(
             _TargetParametrizations, Address, tgt.address.maybe_convert_to_target_generator()
         )
-        generated_addresses = tuple(parametrizations.parametrization_for(tgt.address).keys())
+        generated_addresses = tuple(parametrizations.generated_for(tgt.address).keys())
+
+    # If the target is parametrized, see whether any explicitly provided dependencies are also
+    # parametrized, but with partial/no parameters. If so, fill them in.
+    explicitly_provided_includes: Iterable[Address] = explicitly_provided.includes
+    if request.field.address.is_parametrized and explicitly_provided_includes:
+        explicit_dependency_parametrizations = await MultiGet(
+            Get(_TargetParametrizations, Address, address.maybe_convert_to_target_generator())
+            for address in explicitly_provided_includes
+        )
+
+        explicitly_provided_includes = [
+            parametrizations.get_subset(address, tgt).address
+            for address, parametrizations in zip(
+                explicitly_provided_includes, explicit_dependency_parametrizations
+            )
+        ]
 
     # If the target has `SpecialCasedDependencies`, such as the `archive` target having
     # `files` and `packages` fields, then we possibly include those too. We don't want to always
@@ -1117,7 +1133,7 @@ async def resolve_dependencies(
         addr
         for addr in (
             *generated_addresses,
-            *explicitly_provided.includes,
+            *explicitly_provided_includes,
             *itertools.chain.from_iterable(injected),
             *itertools.chain.from_iterable(inferred),
             *special_cased,
diff --git a/src/python/pants/engine/internals/parametrize.py b/src/python/pants/engine/internals/parametrize.py
--- a/src/python/pants/engine/internals/parametrize.py
+++ b/src/python/pants/engine/internals/parametrize.py
@@ -115,6 +115,18 @@ class _TargetParametrization:
     original_target: Target | None
     parametrization: FrozenDict[Address, Target]
 
+    @property
+    def all(self) -> Iterator[Target]:
+        if self.original_target:
+            yield self.original_target
+        yield from self.parametrization.values()
+
+    def get(self, address: Address) -> Target | None:
+        """Find the Target with an exact Address match, if any."""
+        if self.original_target and self.original_target.address == address:
+            return self.original_target
+        return self.parametrization.get(address)
+
 
 # TODO: This is not the right name for this class, nor the best place for it to live. But it is
 # consumed by both `pants.engine.internals.graph` and `pants.engine.internals.build_files`, and
@@ -122,17 +134,15 @@ class _TargetParametrization:
 class _TargetParametrizations(Collection[_TargetParametrization]):
     """All parametrizations and generated targets for a single input Address.
 
-    If a Target has been parametrized, the input Address might _not_ be present in this output, due
-    to no Target being addressable at the un-parameterized Address.
+    If a Target has been parametrized, the original Target might _not_ be present, due to no Target
+    being addressable at the un-parameterized Address.
     """
 
     @property
     def all(self) -> Iterator[Target]:
         """Iterates over all Target instances which are valid after parametrization."""
         for parametrization in self:
-            if parametrization.original_target:
-                yield parametrization.original_target
-            yield from parametrization.parametrization.values()
+            yield from parametrization.all
 
     @property
     def parametrizations(self) -> dict[Address, Target]:
@@ -141,7 +151,9 @@ def parametrizations(self) -> dict[Address, Target]:
             a: t for parametrization in self for a, t in parametrization.parametrization.items()
         }
 
-    def parametrization_for(self, address: Address) -> FrozenDict[Address, Target]:
+    def generated_for(self, address: Address) -> FrozenDict[Address, Target]:
+        """Find all Targets generated by the given generator Address."""
+        assert not address.is_generated_target
         for parametrization in self:
             if (
                 parametrization.original_target
@@ -152,18 +164,62 @@ def parametrization_for(self, address: Address) -> FrozenDict[Address, Target]:
         raise self._bare_address_error(address)
 
     def get(self, address: Address) -> Target | None:
+        """Find the Target with an exact Address match, if any."""
+        for parametrization in self:
+            instance = parametrization.get(address)
+            if instance is not None:
+                return instance
+        return None
+
+    def get_subset(self, address: Address, consumer: Target) -> Target:
+        """Find the Target with the given Address, or with fields matching the given consumer."""
+        # Check for exact matches.
+        instance = self.get(address)
+        if instance is not None:
+            return instance
+
+        def remaining_fields_match(candidate: Target) -> bool:
+            """Returns true if all Fields absent from the candidate's Address match the consumer."""
+            unspecified_param_field_names = {
+                key for key in candidate.address.parameters.keys() if key not in address.parameters
+            }
+
+            return all(
+                consumer.has_field(field_type) and consumer[field_type] == field_value
+                for field_type, field_value in candidate.field_values.items()
+                if field_type.alias in unspecified_param_field_names
+            )
+
         for parametrization in self:
+            # If the given Address is a subset-match of the parametrization's original Target
+            # (meaning that the user specified an un-parameterized generator Address), then we
+            # need to match against one of the generated Targets instead (because a parametrized
+            # generator does not keep its Fields).
             if (
                 parametrization.original_target
-                and parametrization.original_target.address == address
+                and address.is_parametrized_subset_of(parametrization.original_target.address)
+                and parametrization.parametrization
+                and remaining_fields_match(next(iter(parametrization.parametrization.values())))
             ):
                 return parametrization.original_target
-            instance = parametrization.parametrization.get(address)
-            if instance is not None:
-                return instance
-        return None
+
+            # Else, see whether any of the generated targets match.
+            for candidate in parametrization.parametrization.values():
+                if address.is_parametrized_subset_of(candidate.address) and remaining_fields_match(
+                    candidate
+                ):
+                    return candidate
+
+        raise ValueError(
+            f"The explicit dependency `{address}` of the target at `{consumer.address}` does "
+            "not provide enough address parameters to identify which parametrization of the "
+            "dependency target should be used.\n"
+            f"Target `{address.maybe_convert_to_target_generator()}` can be addressed as:\n"
+            f"{bullet_list(str(t.address) for t in self.all)}"
+        )
 
     def generated_or_generator(self, maybe_generator: Address) -> Iterator[Target]:
+        """Yield either the Target, or the generated Targets for the given Address."""
         for parametrization in self:
             if (
                 not parametrization.original_target


Test patch:
diff --git a/src/python/pants/engine/internals/graph_test.py b/src/python/pants/engine/internals/graph_test.py
--- a/src/python/pants/engine/internals/graph_test.py
+++ b/src/python/pants/engine/internals/graph_test.py
@@ -1165,6 +1165,126 @@ def test_parametrize_atom(generated_targets_rule_runner: RuleRunner) -> None:
     )
 
 
+def test_parametrize_partial_atom(generated_targets_rule_runner: RuleRunner) -> None:
+    assert_generated(
+        generated_targets_rule_runner,
+        Address("demo", target_name="t2"),
+        dedent(
+            """\
+            generated(
+              name='t1',
+              resolve=parametrize('a', 'b'),
+              source='f1.ext',
+            )
+            generated(
+              name='t2',
+              resolve=parametrize('a', 'b'),
+              source='f2.ext',
+              dependencies=[':t1'],
+            )
+            """
+        ),
+        ["f1.ext", "f2.ext"],
+        {
+            MockGeneratedTarget(
+                {
+                    SingleSourceField.alias: "f2.ext",
+                    ResolveField.alias: "a",
+                    Dependencies.alias: [":t1"],
+                },
+                Address("demo", target_name="t2", parameters={"resolve": "a"}),
+                residence_dir="demo",
+            ),
+            MockGeneratedTarget(
+                {
+                    SingleSourceField.alias: "f2.ext",
+                    ResolveField.alias: "b",
+                    Dependencies.alias: [":t1"],
+                },
+                Address("demo", target_name="t2", parameters={"resolve": "b"}),
+                residence_dir="demo",
+            ),
+        },
+        dependencies={
+            "demo:t1@resolve=a": set(),
+            "demo:t1@resolve=b": set(),
+            "demo:t2@resolve=a": {"demo:t1@resolve=a"},
+            "demo:t2@resolve=b": {"demo:t1@resolve=b"},
+        },
+    )
+
+
+def test_parametrize_partial_generator(generated_targets_rule_runner: RuleRunner) -> None:
+    assert_generated(
+        generated_targets_rule_runner,
+        Address("demo", target_name="t2"),
+        dedent(
+            """\
+            generator(
+              name='t1',
+              resolve=parametrize('a', 'b'),
+              sources=['f1.ext'],
+            )
+            generator(
+              name='t2',
+              resolve=parametrize('a', 'b'),
+              sources=['f2.ext'],
+              dependencies=[':t1'],
+            )
+            """
+        ),
+        ["f1.ext", "f2.ext"],
+        {
+            MockGeneratedTarget(
+                {
+                    SingleSourceField.alias: "f2.ext",
+                    ResolveField.alias: "a",
+                    Dependencies.alias: [":t1"],
+                },
+                Address(
+                    "demo",
+                    relative_file_path="f2.ext",
+                    target_name="t2",
+                    parameters={"resolve": "a"},
+                ),
+                residence_dir="demo",
+            ),
+            MockGeneratedTarget(
+                {
+                    SingleSourceField.alias: "f2.ext",
+                    ResolveField.alias: "b",
+                    Dependencies.alias: [":t1"],
+                },
+                Address(
+                    "demo",
+                    relative_file_path="f2.ext",
+                    target_name="t2",
+                    parameters={"resolve": "b"},
+                ),
+                residence_dir="demo",
+            ),
+        },
+        dependencies={
+            "demo/f1.ext:t1@resolve=a": set(),
+            "demo/f1.ext:t1@resolve=b": set(),
+            "demo/f2.ext:t2@resolve=a": {"demo:t1@resolve=a"},
+            "demo/f2.ext:t2@resolve=b": {"demo:t1@resolve=b"},
+            "demo:t1@resolve=a": {
+                "demo/f1.ext:t1@resolve=a",
+            },
+            "demo:t1@resolve=b": {
+                "demo/f1.ext:t1@resolve=b",
+            },
+            "demo:t2@resolve=a": {
+                "demo/f2.ext:t2@resolve=a",
+            },
+            "demo:t2@resolve=b": {
+                "demo/f2.ext:t2@resolve=b",
+            },
+        },
+    )
+
+
 # -----------------------------------------------------------------------------------------------
 # Test FieldSets. Also see `engine/target_test.py`.
 # -----------------------------------------------------------------------------------------------