Repository: mit-ll-responsible-ai/hydra-zen
Pull request #558 (2023-10-25 14:18:49)

Add `zen_exclude` option to `builds` and `make_custom_builds_fn`
This PR adds the ability to exclude parameters - either by name or by pattern (i.e. `callable[[name], bool]`) - from `builds` when `populate_full_signature=True`.

```python
from hydra_zen import builds

def f(x=1, y=2, _z=3): ...

builds(f, populate_full_signature=True, zen_exclude=['y'])  # exclude `y` from config
builds(f, populate_full_signature=True, zen_exclude=lambda x: x.startwith("_")  # exclude any param starting with "_"
```

or

```python
from hydra_zen import make_custom_builds_fn

# a builds fn that always ignores parameters with "private" names from auto-populated parameters.
builds = make_custom_builds_fn(populate_full_signature=True, zen_exclude=lambda x: x.startwith("_"))
```

Note that this does **not** enable one to exclude inherited parameters.

## Implementation details

I have wanted to implement `zen_exclude` for a while, but doing so would make our typing-overloads for `populate_full_signature=True`  double, which I am absolutely not going to do.

Because specifying `zen_exclude` has the same effect, as far as typing is concerned, as a user specifying a target-kwarg in builds, I realized that I can just "smuggle" `zen_exclude` into `**target_kwargs`. The typing overloads work just fine. 

The downside to this is that `zen_exclude` does not explicitly appear in the signature of `builds`. Thus you will not see it autocomplete nor will you get type-checking on this parameter. This is unfortunate but ultimately it is better to have `zen_exclude` with some lack in tooling support rather than not at all.


Patch:
diff --git a/docs/source/changes.rst b/docs/source/changes.rst
index 33f97a23..9156f63c 100644
--- a/docs/source/changes.rst
+++ b/docs/source/changes.rst
@@ -11,7 +11,7 @@ chronological order. All previous releases should still be available on pip.
 .. _v0.12.0:
 
 ----------------------
-0.12.0rc1 - 2023-10-23
+0.12.0rc2 - 2023-11-23
 ----------------------
 
 
@@ -62,6 +62,12 @@ Here is a stripped-down example.
 
 For more details and examples, see :pull:`553`.
 
+Improvements
+------------
+- :func:`~hydra_zen.BuildsFn` was introduced to permit customizable auto-config and type-refinement support in config-creation functions. See :pull:`553`.
+- :func:`~hydra_zen.builds` and :func:`~hydra_zen.make_custom_builds_fn` now accept a `zen_exclude` field for excluding parameters from auto-population, either by name or by pattern. See :pull:`558`.
+
+
 .. _v0.11.0:
 
 -------------------
diff --git a/src/hydra_zen/structured_configs/_implementations.py b/src/hydra_zen/structured_configs/_implementations.py
index 5dbcdfb2..0d243bc7 100644
--- a/src/hydra_zen/structured_configs/_implementations.py
+++ b/src/hydra_zen/structured_configs/_implementations.py
@@ -5,6 +5,7 @@ import inspect
 import sys
 import warnings
 from collections import Counter, deque
+from collections.abc import Collection
 from dataclasses import (  # use this for runtime checks
     MISSING,
     Field as _Field,
@@ -1472,7 +1473,7 @@ class BuildsFn(Generic[T]):
         Type[PartialBuilds[Importable]],
         Type[BuildsWithSig[Type[R], P]],
     ]:
-        """builds(hydra_target, /, *pos_args, zen_partial=None, zen_wrappers=(), zen_meta=None, populate_full_signature=False, hydra_recursive=None, hydra_convert=None, hydra_defaults=None, frozen=False, dataclass_name=None, builds_bases=(), **kwargs_for_target)
+        """builds(hydra_target, /, *pos_args, zen_partial=None, zen_wrappers=(), zen_meta=None, populate_full_signature=False, zen_exclude=(), hydra_recursive=None, hydra_convert=None, hydra_defaults=None, frozen=False, dataclass_name=None, builds_bases=(), **kwargs_for_target)
 
         `builds(target, *args, **kw)` returns a Hydra-compatible config that, when
         instantiated, returns `target(*args, **kw)`.
@@ -1532,13 +1533,18 @@ class BuildsFn(Generic[T]):
             ``<hydra_target>``. These are called "meta" fields.
 
         populate_full_signature : bool, optional (default=False)
-            If ``True``, then the resulting config's signature and fields will be populated
-            according to the signature of ``<hydra_target>``; values also specified in
-            ``**kwargs_for_target`` take precedent.
+            If ``True``, then the resulting config's signature and fields will be
+            populated according to the signature of ``<hydra_target>``; values also
+            specified in ``**kwargs_for_target`` take precedent.
 
-            This option is not available for objects with inaccessible signatures, such as
-            NumPy's various ufuncs.
+            This option is not available for objects with inaccessible signatures, such
+            as NumPy's various ufuncs.
 
+        zen_exclude : Collection[str] | Callable[[str], bool], optional (default=[])
+            Specifies parameter names, or a function for checking names, to exclude
+            those parameters from the config-creation process.
+
+        Note that inherited fields cannot be excluded.
         zen_convert : Optional[ZenConvert]
             A dictionary that modifies hydra-zen's value and type conversion behavior.
             Consists of the following optional key-value pairs (:ref:`zen-convert`):
@@ -1612,8 +1618,9 @@ class BuildsFn(Generic[T]):
         Raises
         ------
         hydra_zen.errors.HydraZenUnsupportedPrimitiveError
-            The provided configured value cannot be serialized by Hydra, nor does hydra-zen
-            provide specialized support for it. See :ref:`valid-types` for more details.
+            The provided configured value cannot be serialized by Hydra, nor does
+            hydra-zen provide specialized support for it. See :ref:`valid-types` for
+            more details.
 
         Notes
         -----
@@ -1792,8 +1799,25 @@ class BuildsFn(Generic[T]):
         x: ???
         'y': foo
 
-        Annotations will be used by Hydra to provide limited runtime type-checking during
-        instantiation. Here, we'll pass a float for ``x``, which expects a boolean value.
+        `zen_exclude` can be used to either name parameter to be excluded from the
+        auto-population process:
+
+        >>> Conf2 = builds(bar, populate_full_signature=True, zen_exclude=["y"])
+        >>> pyaml(Conf2)
+        _target_: __main__.bar
+        x: ???
+        'y': foo
+
+        or specify a pattern - via a function - for excluding parameters:
+
+        >>> Conf3 = builds(bar, populate_full_signature=True, zen_exclude=lambda name: name.startswith("x"))
+        >>> pyaml(Conf3)
+        _target_: __main__.bar
+        'y': foo
+
+        Annotations will be used by Hydra to provide limited runtime type-checking
+        during instantiation. Here, we'll pass a float for ``x``, which expects a
+        boolean value.
 
         >>> instantiate(Conf(x=10.0))  # type: ignore
         ValidationError: Value '10.0' is not a valid bool (type float)
@@ -1888,14 +1912,14 @@ class BuildsFn(Generic[T]):
 
         .. code-block:: python
 
-        # contents of mylib/foo.py
-        from pickle import dumps, loads
+           # contents of mylib/foo.py
+           from pickle import dumps, loads
 
-        DictConf = builds(dict,
-                            zen_dataclass={'module': 'mylib.foo',
-                                            'cls_name': 'DictConf'})
+           DictConf = builds(dict,
+                               zen_dataclass={'module': 'mylib.foo',
+                                               'cls_name': 'DictConf'})
 
-        assert DictConf is loads(dumps(DictConf))
+           assert DictConf is loads(dumps(DictConf))
 
 
         **Creating a frozen config**
@@ -1987,6 +2011,21 @@ class BuildsFn(Generic[T]):
 
         del pos_args
 
+        zen_exclude: Callable[[str], bool] = kwargs_for_target.pop(
+            "zen_exclude", frozenset()
+        )
+
+        if (
+            not isinstance(zen_exclude, Collection) or isinstance(zen_exclude, str)
+        ) and not callable(zen_exclude):
+            raise TypeError(
+                f"`zen_exclude` must be a non-string collection of strings, or "
+                f"callable[[str], bool]. Got {zen_exclude}"
+            )
+
+        if isinstance(zen_exclude, Collection):
+            zen_exclude = set(zen_exclude).__contains__
+
         if not callable(target):
             raise TypeError(
                 BUILDS_ERROR_PREFIX
@@ -2549,6 +2588,7 @@ class BuildsFn(Generic[T]):
         user_specified_named_params: Dict[str, Tuple[str, type, Any]] = {
             name: (name, type_hints.get(name, Any), value)
             for name, value in kwargs_for_target.items()
+            if not zen_exclude(name)
         }
 
         if populate_full_signature is True:
@@ -2569,6 +2609,9 @@ class BuildsFn(Generic[T]):
             _seen: Set[str] = set()
 
             for n, param in enumerate(signature_params.values()):
+                if zen_exclude(param.name):
+                    continue
+
                 if n + 1 <= len(_pos_args):
                     # Positional parameters are populated from "left to right" in the signature.
                     # We have already done validation, so we know that positional params aren't redundant
diff --git a/src/hydra_zen/structured_configs/_make_custom_builds.py b/src/hydra_zen/structured_configs/_make_custom_builds.py
index 117fcb68..024a7f91 100644
--- a/src/hydra_zen/structured_configs/_make_custom_builds.py
+++ b/src/hydra_zen/structured_configs/_make_custom_builds.py
@@ -8,6 +8,7 @@ from typing import (
     TYPE_CHECKING,
     Any,
     Callable,
+    Collection,
     Dict,
     Mapping,
     Optional,
@@ -36,6 +37,7 @@ __BUILDS_DEFAULTS: Final[Dict[str, Any]] = {
     for name, p in _builds_sig.parameters.items()
     if p.kind is p.KEYWORD_ONLY
 }
+__BUILDS_DEFAULTS["zen_exclude"] = frozenset()
 # TODO: Remove deprecated options once they are phased out
 __BUILDS_DEFAULTS["frozen"] = False
 __BUILDS_DEFAULTS["dataclass_name"] = None
@@ -55,6 +57,7 @@ def make_custom_builds_fn(
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, Any]] = ...,
     populate_full_signature: Literal[True],
+    zen_exclude: Union[Collection[str], Callable[[str], bool]] = ...,
     hydra_recursive: Optional[bool] = ...,
     hydra_convert: Optional[Literal["none", "partial", "all", "object"]] = ...,
     zen_dataclass: Optional[DataclassOptions] = ...,
@@ -73,6 +76,7 @@ def make_custom_builds_fn(
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, Any]] = ...,
     populate_full_signature: bool = ...,
+    zen_exclude: Union[Collection[str], Callable[[str], bool]] = ...,
     hydra_recursive: Optional[bool] = ...,
     hydra_convert: Optional[Literal["none", "partial", "all", "object"]] = ...,
     zen_dataclass: Optional[DataclassOptions] = ...,
@@ -89,6 +93,7 @@ def make_custom_builds_fn(
     *,
     zen_partial: Literal[False, None] = ...,
     populate_full_signature: Literal[False] = ...,
+    zen_exclude: Union[Collection[str], Callable[[str], bool]] = ...,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, Any]] = ...,
     hydra_recursive: Optional[bool] = ...,
@@ -107,6 +112,7 @@ def make_custom_builds_fn(
     *,
     zen_partial: Literal[False, None] = ...,
     populate_full_signature: bool,
+    zen_exclude: Union[Collection[str], Callable[[str], bool]] = ...,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, Any]] = ...,
     hydra_recursive: Optional[bool] = ...,
@@ -125,6 +131,7 @@ def make_custom_builds_fn(
     *,
     zen_partial: Union[bool, None],
     populate_full_signature: Literal[False] = ...,
+    zen_exclude: Union[Collection[str], Callable[[str], bool]] = ...,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, Any]] = ...,
     hydra_recursive: Optional[bool] = ...,
@@ -143,6 +150,7 @@ def make_custom_builds_fn(
     *,
     zen_partial: Union[bool, None],
     populate_full_signature: bool,
+    zen_exclude: Union[Collection[str], Callable[[str], bool]] = ...,
     zen_wrappers: ZenWrappers[Callable[..., Any]] = ...,
     zen_meta: Optional[Mapping[str, Any]] = ...,
     hydra_recursive: Optional[bool] = ...,
@@ -159,6 +167,7 @@ def make_custom_builds_fn(
     *,
     zen_partial: Optional[bool] = None,
     populate_full_signature: bool = False,
+    zen_exclude: Union[Collection[str], Callable[[str], bool]] = frozenset(),
     zen_wrappers: ZenWrappers[Callable[..., Any]] = tuple(),
     zen_meta: Optional[Mapping[str, Any]] = None,
     hydra_recursive: Optional[bool] = None,
@@ -188,6 +197,12 @@ def make_custom_builds_fn(
     populate_full_signature : bool, optional (default=False)
         Specifies a new the default value for ``builds(..., populate_full_signature=<..>)``
 
+    zen_exclude : Collection[str] | Callable[[str], bool], optional (default=[])
+        Specifies parameter names, or a function for checking names, to exclude
+        those parameters from the config-creation process.
+
+        Note that inherited fields cannot be excluded.
+
     zen_convert : Optional[ZenConvert]
         A dictionary that modifies hydra-zen's value and type conversion behavior.
         Consists of the following optional key-value pairs (:ref:`zen-convert`):


Test patch:
diff --git a/tests/custom_strategies.py b/tests/custom_strategies.py
index 61e9d743..9c6ca2ac 100644
--- a/tests/custom_strategies.py
+++ b/tests/custom_strategies.py
@@ -91,6 +91,8 @@ _valid_builds_strats = dict(
     builds_bases=st.just(()),
     zen_convert=st.none() | st.from_type(ZenConvert),
     zen_dataclass=st.none() | st.from_type(DataclassOptions),
+    zen_exclude=st.just(())
+    | st.sampled_from([set(), ["momomomo"], lambda x: x.endswith("momomomo")]),
 )
 
 
diff --git a/tests/test_custom_strategies.py b/tests/test_custom_strategies.py
index 6bad4e9c..df6a5969 100644
--- a/tests/test_custom_strategies.py
+++ b/tests/test_custom_strategies.py
@@ -28,6 +28,7 @@ def test_valid_build_strats_are_exhaustive():
         for n, p in inspect.signature(builds).parameters.items()
         if p.kind is p.KEYWORD_ONLY
     )
+    nameable_builds_args.add("zen_exclude")
     assert nameable_builds_args - {"dataclass_name", "hydra_defaults"} == set(
         _valid_builds_strats
     )
diff --git a/tests/test_zen_exclude.py b/tests/test_zen_exclude.py
new file mode 100644
index 00000000..0b418711
--- /dev/null
+++ b/tests/test_zen_exclude.py
@@ -0,0 +1,37 @@
+# Copyright (c) 2023 Massachusetts Institute of Technology
+# SPDX-License-Identifier: MIT
+import pytest
+
+from hydra_zen import builds, instantiate, make_custom_builds_fn
+
+
+@pytest.mark.parametrize("bad_exclude", [1, "x"])
+def test_validate_exclude(bad_exclude):
+    with pytest.raises(TypeError):
+        builds(dict, zen_exclude=bad_exclude)
+
+
+def foo(x=1, _y=2, _z=3):
+    return "apple"
+
+
+@pytest.mark.parametrize("partial", [True, False])
+@pytest.mark.parametrize("custom_builds", [True, False])
+@pytest.mark.parametrize("exclude", [["_y", "_z"], lambda x: x.startswith("_")])
+def test_exclude_named(partial: bool, custom_builds: bool, exclude):
+    if custom_builds:
+        b = make_custom_builds_fn(
+            populate_full_signature=True, zen_partial=partial, zen_exclude=exclude
+        )
+        conf = b(foo)()
+    else:
+        conf = builds(
+            foo, populate_full_signature=True, zen_partial=partial, zen_exclude=exclude
+        )()
+    assert not hasattr(conf, "_y")
+    assert not hasattr(conf, "_z")
+    assert conf.x == 1
+    if not partial:
+        assert instantiate(conf) == "apple"
+    else:
+        assert instantiate(conf)() == "apple"  # type: ignore