Repository: pantsbuild/pants

Issue:
Support `parametrize(...)` assignments in the `__defaults__` macro
**Is your feature request related to a problem? Please describe.**

The `__defaults__` BUILD file construct helps eliminate the need for boilerplate target field assignments across an entire
directory tree. However, it does not support assigning the result of the `parametrize` macro to a field.

```
# BUILD

__defaults__({
    python_sources: dict(resolve=parametrize(my_resolves())), # InvalidFieldTypeException: expects string, not Parametrize
})
```

**Describe the solution you'd like**

Support `parametrize` in this context in the natural way, i.e. interpret applicable targets as having the field assigned to
`parametrize(...)`.

**Describe alternatives you've considered**

Modifying `tailor` to populate this field on a per-directory basis might work.

**Additional context**

This is very useful when a single Python subproject is intended to run under several lockfiles.


Discussion:

Resolving patch:
diff --git a/src/python/pants/engine/internals/defaults.py b/src/python/pants/engine/internals/defaults.py
--- a/src/python/pants/engine/internals/defaults.py
+++ b/src/python/pants/engine/internals/defaults.py
@@ -13,9 +13,10 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
-from typing import Any, Iterable, Mapping, Tuple, Union
+from typing import Any, Callable, Iterable, Mapping, Tuple, Union
 
 from pants.engine.addresses import Address
+from pants.engine.internals.parametrize import Parametrize
 from pants.engine.target import (
     Field,
     ImmutableValue,
@@ -26,6 +27,7 @@
 )
 from pants.engine.unions import UnionMembership
 from pants.util.frozendict import FrozenDict
+from pants.util.meta import frozen_after_init
 
 SetDefaultsValueT = Mapping[str, Any]
 SetDefaultsKeyT = Union[str, Tuple[str, ...]]
@@ -36,6 +38,36 @@ class BuildFileDefaults(FrozenDict[str, FrozenDict[str, ImmutableValue]]):
     """Map target types to default field values."""
 
 
+@frozen_after_init
+@dataclass(unsafe_hash=True)
+class ParametrizeDefault(Parametrize):
+    """A frozen version of `Parametrize` for defaults.
+
+    This is needed since all defaults must be hashable, which the `Parametrize` class is not nor can
+    it be as it may get unhashable data as input and is unaware of the field type it is being
+    applied to.
+    """
+
+    args: tuple[str, ...]
+    kwargs: FrozenDict[str, ImmutableValue]  # type: ignore[assignment]
+
+    def __init__(self, *args: str, **kwargs: ImmutableValue) -> None:
+        self.args = args
+        self.kwargs = FrozenDict(kwargs)
+
+    @classmethod
+    def create(
+        cls, freeze: Callable[[Any], ImmutableValue], parametrize: Parametrize
+    ) -> ParametrizeDefault:
+        return cls(
+            *map(freeze, parametrize.args),
+            **{kw: freeze(arg) for kw, arg in parametrize.kwargs.items()},
+        )
+
+    def __repr__(self) -> str:
+        return super().__repr__()
+
+
 @dataclass
 class BuildFileDefaultsParserState:
     address: Address
@@ -58,15 +90,25 @@ def create(
             union_membership=union_membership,
         )
 
+    def _freeze_field_value(self, field_type: type[Field], value: Any) -> ImmutableValue:
+        if isinstance(value, ParametrizeDefault):
+            return value
+        elif isinstance(value, Parametrize):
+
+            def freeze(v: Any) -> ImmutableValue:
+                return self._freeze_field_value(field_type, v)
+
+            return ParametrizeDefault.create(freeze, value)
+        else:
+            return field_type.compute_value(raw_value=value, address=self.address)
+
     def get_frozen_defaults(self) -> BuildFileDefaults:
         types = self.registered_target_types.aliases_to_types
         return BuildFileDefaults(
             {
                 target_alias: FrozenDict(
                     {
-                        field_type.alias: field_type.compute_value(
-                            raw_value=default, address=self.address
-                        )
+                        field_type.alias: self._freeze_field_value(field_type, default)
                         for field_alias, default in fields.items()
                         for field_type in self._target_type_field_types(types[target_alias])
                         if field_alias in (field_type.alias, field_type.deprecated_alias)
@@ -162,8 +204,5 @@ def _process_defaults(
                                 f"Valid fields are: {', '.join(sorted(valid_field_aliases))}.",
                             )
 
-                # TODO: support parametrization ? --needs special care due to Parametrize object not
-                # being hashable, and thus not acceptable in a FrozenDict instance.
-
                 # Merge all provided defaults for this call.
                 defaults.setdefault(target_type.alias, {}).update(raw_values)


Test patch:
diff --git a/src/python/pants/engine/internals/build_files_test.py b/src/python/pants/engine/internals/build_files_test.py
--- a/src/python/pants/engine/internals/build_files_test.py
+++ b/src/python/pants/engine/internals/build_files_test.py
@@ -20,6 +20,8 @@
     evaluate_preludes,
     parse_address_family,
 )
+from pants.engine.internals.defaults import ParametrizeDefault
+from pants.engine.internals.parametrize import Parametrize
 from pants.engine.internals.parser import BuildFilePreludeSymbols, Parser
 from pants.engine.internals.scheduler import ExecutionError
 from pants.engine.internals.target_adaptor import TargetAdaptor, TargetAdaptorRequest
@@ -202,7 +204,9 @@ def assert_is_expected(address_input: AddressInput, expected: Address) -> None:
 @pytest.fixture
 def target_adaptor_rule_runner() -> RuleRunner:
     return RuleRunner(
-        rules=[QueryRule(TargetAdaptor, (TargetAdaptorRequest,))], target_types=[MockTgt]
+        rules=[QueryRule(TargetAdaptor, (TargetAdaptorRequest,))],
+        target_types=[MockTgt],
+        objects={"parametrize": Parametrize},
     )
 
 
@@ -317,6 +321,28 @@ def test_inherit_defaults(target_adaptor_rule_runner: RuleRunner) -> None:
     assert target_adaptor.kwargs["tags"] == ("root",)
 
 
+def test_parametrize_defaults(target_adaptor_rule_runner: RuleRunner) -> None:
+    target_adaptor_rule_runner.write_files(
+        {
+            "BUILD": dedent(
+                """\
+                __defaults__(
+                  all=dict(
+                    tags=parametrize(a=["a", "root"], b=["non-root", "b"])
+                  )
+                )
+                """
+            ),
+            "helloworld/dir/BUILD": "mock_tgt()",
+        }
+    )
+    target_adaptor = target_adaptor_rule_runner.request(
+        TargetAdaptor,
+        [TargetAdaptorRequest(Address("helloworld/dir"), description_of_origin="tests")],
+    )
+    assert target_adaptor.kwargs["tags"] == ParametrizeDefault(a=("a", "root"), b=("non-root", "b"))
+
+
 def test_target_adaptor_not_found(target_adaptor_rule_runner: RuleRunner) -> None:
     with pytest.raises(ExecutionError) as exc:
         target_adaptor_rule_runner.request(
diff --git a/src/python/pants/engine/internals/defaults_test.py b/src/python/pants/engine/internals/defaults_test.py
--- a/src/python/pants/engine/internals/defaults_test.py
+++ b/src/python/pants/engine/internals/defaults_test.py
@@ -8,7 +8,12 @@
 import pytest
 
 from pants.core.target_types import GenericTarget, GenericTargetDependenciesField
-from pants.engine.internals.defaults import BuildFileDefaults, BuildFileDefaultsParserState
+from pants.engine.internals.defaults import (
+    BuildFileDefaults,
+    BuildFileDefaultsParserState,
+    ParametrizeDefault,
+)
+from pants.engine.internals.parametrize import Parametrize
 from pants.engine.target import (
     COMMON_TARGET_FIELDS,
     Dependencies,
@@ -224,6 +229,17 @@ def test_assumptions(
             ),
             id="default value for moved field",
         ),
+        pytest.param(
+            Scenario(
+                args=({Test1Target.alias: {"tags": Parametrize(["foo"], ["bar"], baz=["baz"])}},),
+                expected_defaults={
+                    "test_type_1": {
+                        "tags": ParametrizeDefault(("foo",), ("bar",), baz=("baz",)),  # type: ignore[arg-type]
+                    }
+                },
+            ),
+            id="parametrize default field value",
+        ),
     ],
 )
 def test_set_defaults(