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

Issue:
Provide support for `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, zen_partial=True)
```

so that we get:

```python
>>> builds(partial(dict))
types.PartialBuilds_dict
```


Discussion:

Patch:
diff --git a/docs/source/api_reference.rst b/docs/source/api_reference.rst
index b7f50b66..535faaa3 100644
--- a/docs/source/api_reference.rst
+++ b/docs/source/api_reference.rst
@@ -121,6 +121,8 @@ Values of the following types can be specified directly in configs:
 - :py:class:`enum.Enum`
 
 
+.. _additional-types:
+
 Additional Types, Supported via hydra-zen
 *****************************************
 
@@ -141,7 +143,7 @@ with Hydra. For example, a :py:class:`complex` value can be specified directly v
      imag: 3.0
      _target_: builtins.complex
 
-hydra-zen provides such support for values of the following types:
+hydra-zen provides specialized support for values of the following types:
 
 - :py:class:`bytes`
 - :py:class:`bytearray`
diff --git a/docs/source/changes.rst b/docs/source/changes.rst
index 00bf9bd7..38472fd9 100644
--- a/docs/source/changes.rst
+++ b/docs/source/changes.rst
@@ -8,12 +8,19 @@ chronological order. All previous releases should still be available on pip.
 .. _v0.5.0:
 
 ---------------------
-0.5.0rc1 - 2022-01-10
+0.5.0rc2 - 2022-01-13
 ---------------------
 
 This release primarily improves the ability of :func:`~hydra_zen.builds` to inspect and
-the signatures of its targets; thus its ability to both auto-generate and validate configs
-is improved. 
+the signatures of its targets; thus its ability to both auto-generate and validate 
+configs is improved. This includes automatic support for specifying "partial'd" objects 
+-- objects produced by :py:func:`functools.partial` -- as configured values, and even as
+the target of :func:`~hydra_zen.builds`.
+
+New Features
+------------
+- Objects produced by :py:func:`functools.partial` can now be specified directly as configured values in :func:`~hydra_zen.make_config` and :func:`~hydra_zen.builds`. See :pull:`198` for examples.
+- An object produced by :py:func:`functools.partial` can now be specified as the target of :func:`~hydra_zen.builds`; ``builds`` will automatically "unpack" this partial'd object and incorporate its arguments into the config. See :pull:`199` for examples.
 
 Improvements
 ------------
diff --git a/src/hydra_zen/structured_configs/_implementations.py b/src/hydra_zen/structured_configs/_implementations.py
index 6ece8afe..210413bd 100644
--- 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 hydrated_dataclass(
 
 
 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
index 0a2c437b..82447ce3 100644
--- a/src/hydra_zen/typing/_implementations.py
+++ b/src/hydra_zen/typing/_implementations.py
@@ -127,6 +127,7 @@ _SupportedPrimitive = Union[
     _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
index 9a281e50..4eb618ad 100644
--- 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
index 9dc8decb..3bfda54b 100644
--- 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)