Repository: mit-ll-responsible-ai/hydra-zen

Issue:
Provide support for `functools.partial` within config-creation funcs
Note that this issue is not describing support for creating a partial-config; we already have this capability:

```python
>>> instantiate(builds(dict, a=1, zen_partial=True))
functools.partial(dict, a=1)
```

rather it is about accommodating user-supplied/default values and builds-targets that are themselves partial'd targets.

## Supporting `builds(functools.partial(<target>, ...), ...)`

Currently, attempting to use `builds` on a "partial'd" target raises an error because the `__name__` of the object cannot be resolved (which is needed to determine `_target_`):

```python
from hydra_zen import builds

from functools import partial
```
```python
>>> builds(partial(dict))
---------------------------------------------------------------------------
AttributeError 
AttributeError: functools.partial(<class 'dict'>) does not have a `__name__` attribute
```

We can actually support this by "unpacking" the partial'd object. Under the hood, `builds` can detect if `target` "looks like" a partial and do:

```python
 return builds(target.func, *target.args, **target.kwargs)
```

so that we get:

```python
>>> Conf = builds(partial(dict, a=2))
>>> Conf
types.Builds_dict
>>> instantiate(Conf)  # should produce same result as `partial(dict, a=2)()`
{'a': 2}
```

As an aside, we should improve our error-reporting in this case. The reported attribute error will likely not lead users to a solution.

## Supporting `builds(target, x=functools.partial(func, a=1))`

We can also support cases where a configured field is itself a partial'd object. We can similarly "unpack" the partial, but this time we will apply `zen_partial=True` so that the config instantiates back to the partial'd object.

E.g.

```python
Conf = make_config(x=functools.partial(dict, a=1))
```

will effectively be equivalent to

```python
Conf = make_config(x=builds(dict, a=1, zen_partial=True))
```

so that

```python
>>> obj = instantiate(make_config(x=functools.partial(dict, a=1)))
>>> obj.x
functools.partial(dict, a=1)
```


Discussion:

Resolving patch:
diff --git a/src/hydra_zen/structured_configs/_implementations.py b/src/hydra_zen/structured_configs/_implementations.py
--- a/src/hydra_zen/structured_configs/_implementations.py
+++ b/src/hydra_zen/structured_configs/_implementations.py
@@ -251,7 +251,8 @@ def hydrated_dataclass(
     Hydra-specific fields for specifying a targeted config [1]_.
 
     This provides similar functionality to `builds`, but enables a user to define
-    a config explicitly using the :func:`dataclasses.dataclass` syntax.
+    a config explicitly using the :func:`dataclasses.dataclass` syntax, which can
+    enable enhanced static analysis of the resulting config.
 
     Parameters
     ----------
@@ -455,8 +456,7 @@ def wrapper(decorated_obj: Any) -> Any:
 
 
 def just(obj: Importable) -> Type[Just[Importable]]:
-    """Produces a targeted config that, when instantiated by Hydra, 'just'
-    returns the target (un-instantiated).
+    """Produces a config that, when instantiated by Hydra, "just" returns the un-instantiated target-object.
 
     Parameters
     ----------
@@ -751,7 +751,7 @@ def builds(
     frozen=False, dataclass_name=None, builds_bases=(), **kwargs_for_target)
 
     Returns a config, which describes how to instantiate/call ``<hydra_target>`` with
-    user-specified and auto-populated parameter values.
+    both user-specified and auto-populated parameter values.
 
     Consult the Examples section of the docstring to see the various features of
     `builds` in action.
@@ -985,6 +985,26 @@ def builds(
     >>> issubclass(ChildConf, ParentConf)
     True
 
+    .. _builds-validation:
+
+    **Runtime validation perfomed by builds**
+
+    Misspelled parameter names and other invalid configurations for the target’s
+    signature will be caught by `builds`, so that such errors are caught prior to
+    instantiation.
+
+    >>> def func(a_number: int): pass
+
+    >>> builds(func, a_nmbr=2)  # misspelled parameter name
+    TypeError: Building: func ..
+
+    >>> builds(func, 1, 2)  # too many arguments
+    TypeError: Building: func ..
+
+    >>> BaseConf = builds(func, a_number=2)
+    >>> builds(func, 1, builds_bases=(BaseConf,))  # too many args (via inheritance)
+    TypeError: Building: func ..
+
     .. _meta-field:
 
     **Using meta-fields**
@@ -1040,25 +1060,18 @@ def builds(
     >>> my_router.ip_address = "148.109.37.2"
     FrozenInstanceError: cannot assign to field 'ip_address'
 
-    .. _builds-validation:
+    **Support for partial'd objects**
 
-    **Runtime validation perfomed by builds**
-
-    Misspelled parameter names and other invalid configurations for the target’s
-    signature will be caught by `builds`, so that the error surfaces immediately while
-    creating the configuration.
+    Specifying ``builds(functools.partial(<target>, ...), ...)`` is supported; `builds`
+    will automatically "unpack" a partial'd object that is passed as its target.
 
-    >>> def func(a_number: int): pass
-
-    >>> builds(func, a_nmbr=2)  # misspelled parameter name
-    TypeError: Building: func ..
-
-    >>> builds(func, 1, 2)  # too many arguments
-    TypeError: Building: func ..
-
-    >>> BaseConf = builds(func, a_number=2)
-    >>> builds(func, 1, builds_bases=(BaseConf,))  # too many args (via inheritance)
-    TypeError: Building: func ..
+    >>> import functools
+    >>> partiald_dict = functools.partial(dict, a=1, b=2)
+    >>> Conf = builds(partiald_dict)  # signature: (a = 1, b = 2)
+    >>> Conf.a, Conf.b
+    (1, 2)
+    >>> instantiate(Conf(a=-4))  # equivalent to calling: `partiald_dict(a=-4)`
+    {'a': -4, 'b': 2}
     """
 
     if not pos_args and not kwargs_for_target:
@@ -1075,6 +1088,14 @@ def builds(
 
     target, *_pos_args = pos_args
 
+    if isinstance(target, functools.partial):
+        # partial'd args must come first, then user-specified args
+        # otherwise, the parial'd args will take precedent, which
+        # does not align with the behavior of partial itself
+        _pos_args = list(target.args) + _pos_args
+        kwargs_for_target = {**target.keywords, **kwargs_for_target}
+        target = target.func
+
     BUILDS_ERROR_PREFIX = _utils.building_error_prefix(target)
 
     del pos_args
@@ -2203,7 +2224,8 @@ def make_config(
     Examples
     --------
     >>> from hydra_zen import make_config, to_yaml
-    >>> def pp(x): return print(to_yaml(x))  # pretty-print config as yaml
+    >>> def pp(x):
+    ...     return print(to_yaml(x))  # pretty-print config as yaml
 
     **Basic Usage**
 
@@ -2267,8 +2289,8 @@ def make_config(
 
     **Support for Additional Types**
 
-    Types like :py:class:`complex` and :py:class:`pathlib.Path` are automatically supported by
-    hydra-zen.
+    Types like :py:class:`complex` and :py:class:`pathlib.Path` are automatically
+    supported by hydra-zen.
 
     >>> ConfWithComplex = make_config(a=1+2j)
     >>> pp(ConfWithComplex)
@@ -2277,6 +2299,7 @@ def make_config(
       imag: 2.0
       _target_: builtins.complex
 
+    See :ref:`additional-types` for a complete list of supported types.
 
     **Using ZenField to Provide Type Information**
 
@@ -2288,7 +2311,8 @@ def make_config(
     >>> # signature: ProfileConf(username: str, age: int)
 
     Providing type annotations is optional, but doing so enables Hydra to perform
-    checks at runtime to ensure that a configured value matches its associated type [4]_.
+    checks at runtime to ensure that a configured value matches its associated
+    type [4]_.
 
     >>> pp(ProfileConf(username="piro", age=False))  # age should be an integer
     <ValidationError: Value 'False' could not be converted to Integer>
diff --git a/src/hydra_zen/typing/_implementations.py b/src/hydra_zen/typing/_implementations.py
--- a/src/hydra_zen/typing/_implementations.py
+++ b/src/hydra_zen/typing/_implementations.py
@@ -127,6 +127,7 @@ class HasPartialTarget(Protocol):  # pragma: no cover
     _DataClass,
     complex,
     Path,
+    Partial,
     range,
     set,
     EmptyDict,  # not covered by Mapping[..., ...]


Test patch:
diff --git a/tests/annotations/declarations.py b/tests/annotations/declarations.py
--- a/tests/annotations/declarations.py
+++ b/tests/annotations/declarations.py
@@ -359,6 +359,18 @@ def check_partial_protocol():
     # x = partial(str)  # should fail
 
 
+def check_partiald_target():
+    A: Literal["Type[Builds[partial[int]]]"] = reveal_type(builds(partial(int)))
+    B: Literal["Type[PartialBuilds[partial[int]]]"] = reveal_type(
+        builds(partial(int), zen_partial=True)
+    )
+    a = builds(partial(int))
+    out_a: Literal["int"] = reveal_type(instantiate(a))
+
+    b = builds(partial(int), zen_partial=True)
+    out_b: Literal["Partial[int]"] = reveal_type(instantiate(b))
+
+
 def check_target_annotation():
     builds(int)
     builds(print)
diff --git a/tests/test_value_conversion.py b/tests/test_value_conversion.py
--- a/tests/test_value_conversion.py
+++ b/tests/test_value_conversion.py
@@ -168,3 +168,34 @@ def test_functools_partial_gets_validated():
 
     with pytest.raises(TypeError):
         make_config(x=partial(f3, y=2))  # no param named `y`
+
+
+def f4(a, b=1, c="2"):
+    return a, b, c
+
+
+@settings(max_examples=500)
+@given(
+    partial_args=st.lists(st.integers(1, 4), max_size=3),
+    partial_kwargs=st.dictionaries(
+        keys=st.sampled_from("abc"), values=st.integers(-5, -2)
+    ),
+    args=st.lists(st.integers(10, 14), max_size=3),
+    kwargs=st.dictionaries(keys=st.sampled_from("abc"), values=st.integers(-5, -2)),
+)
+def test_functools_partial_as_target(partial_args, partial_kwargs, args, kwargs):
+    # Ensures that resolving a partial'd object behaves the exact same way as
+    # configuring the object via `builds` and instantiating it.
+    partiald_obj = partial(f4, *partial_args, **partial_kwargs)
+    try:
+        # might be under or over-specified
+        out = partiald_obj(*args, **kwargs)
+    except Exception as e:
+        # `builds` should raise the same error, if over-specified
+        # (under-specified configs are ok)
+        if partial_args or args or "a" in partial_kwargs or "a" in kwargs:
+            with pytest.raises(type(e)):
+                builds(partiald_obj, *args, **kwargs)
+    else:
+        Conf = builds(partiald_obj, *args, **kwargs)
+        assert out == instantiate(Conf)