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

Issue:
Add support for *args in `builds`
It looks like omegaconf supports positional args in structured configs now:

```python
def f(*args): return list(args)

@dataclass
class C:
    _target_: str = '__main__.f'
    _args_: Any = (1, 2, 3)

>>> instantiate(C)
[1, 2, 3]
```

Time to add `*args` to builds! 🙌 


Discussion:
Added to hydra here:

https://github.com/facebookresearch/hydra/pull/1470

Resolving patch:
diff --git a/src/hydra_zen/funcs.py b/src/hydra_zen/funcs.py
--- a/src/hydra_zen/funcs.py
+++ b/src/hydra_zen/funcs.py
@@ -21,13 +21,13 @@
 
 
 def partial(
-    _partial_target_: Callable[..., _T], *args: Any, **kwargs: Any
+    *args: Any, _partial_target_: Callable[..., _T], **kwargs: Any
 ) -> Partial[_T]:
     """Equivalent to ``functools.partial`` but provides a named parameter for the callable."""
     return _functools.partial(_partial_target_, *args, **kwargs)
 
 
-def get_obj(path: str) -> Union[type, Callable[..., Any]]:
+def get_obj(*, path: str) -> Union[type, Callable[..., Any]]:
     """Imports an object given the specified path."""
     try:
         cl = hydra_internal_utils._locate(path)
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
@@ -5,10 +5,12 @@
 from collections import defaultdict
 from dataclasses import Field, dataclass, field, fields, is_dataclass, make_dataclass
 from functools import partial
+from itertools import chain
 from typing import (
     Any,
     Callable,
     Dict,
+    FrozenSet,
     List,
     Mapping,
     Optional,
@@ -38,7 +40,27 @@
 _T = TypeVar("_T")
 
 _TARGET_FIELD_NAME: Final[str] = "_target_"
+_RECURSIVE_FIELD_NAME: Final[str] = "_recursive_"
+_CONVERT_FIELD_NAME: Final[str] = "_convert_"
 _PARTIAL_TARGET_FIELD_NAME: Final[str] = "_partial_target_"
+_POS_ARG_FIELD_NAME: Final[str] = "_args_"
+_HYDRA_FIELD_NAMES: FrozenSet[str] = frozenset(
+    (
+        _TARGET_FIELD_NAME,
+        _RECURSIVE_FIELD_NAME,
+        _CONVERT_FIELD_NAME,
+        _PARTIAL_TARGET_FIELD_NAME,
+        _POS_ARG_FIELD_NAME,
+    )
+)
+
+_POSITIONAL_ONLY: Final = inspect.Parameter.POSITIONAL_ONLY
+_POSITIONAL_OR_KEYWORD: Final = inspect.Parameter.POSITIONAL_OR_KEYWORD
+_VAR_POSITIONAL: Final = inspect.Parameter.VAR_POSITIONAL
+_KEYWORD_ONLY: Final = inspect.Parameter.KEYWORD_ONLY
+_VAR_KEYWORD: Final = inspect.Parameter.VAR_KEYWORD
+
+_builtin_function_or_method_type = type(len)
 
 
 def mutable_value(x: Any) -> Field:
@@ -90,7 +112,7 @@ def __dataclass_transform__(
 @__dataclass_transform__()
 def hydrated_dataclass(
     target: Callable,
-    *,
+    *pos_args: Any,
     populate_full_signature: bool = False,
     hydra_partial: bool = False,
     hydra_recursive: bool = True,
@@ -105,6 +127,9 @@ def hydrated_dataclass(
     target : Union[Instantiable, Callable]
         The object to be instantiated/called.
 
+    *pos_args: Any
+        Positional arguments passed to `target`
+
     populate_full_signature : bool, optional (default=False)
         If True, then the resulting dataclass's __init__ signature and fields
         will be populated according to the signature of `target`.
@@ -193,7 +218,7 @@ def wrapper(decorated_obj: Any) -> Any:
 
         if not isinstance(decorated_obj, type):
             raise NotImplementedError(
-                "Class instances are not supported by `hydrated_dataclass` (yet)."
+                "Class instances are not supported by `hydrated_dataclass`."
             )
 
         # TODO: We should mutate `decorated_obj` directly like @dataclass does.
@@ -205,6 +230,7 @@ def wrapper(decorated_obj: Any) -> Any:
 
         return builds(
             target,
+            *pos_args,
             populate_full_signature=populate_full_signature,
             hydra_recursive=hydra_recursive,
             hydra_convert=hydra_convert,
@@ -277,28 +303,33 @@ def just(obj: Importable) -> Type[Just[Importable]]:
     return out_class
 
 
-def sanitized_default_value(value: Any) -> Union[Field, Type[Just]]:
-    if isinstance(value, _utils.KNOWN_MUTABLE_TYPES):
-        return mutable_value(value)
+def create_just_if_needed(value: _T) -> Union[_T, Type[Just[_T]]]:
+    # Hydra can serialize dataclasses directly, thus we
+    # don't want to wrap these in `just`
 
-    if inspect.isfunction(value) or (
-        inspect.isclass(value) and not is_dataclass(value)
+    if callable(value) and (
+        inspect.isfunction(value)
+        or (inspect.isclass(value) and not is_dataclass(value))
+        or isinstance(value, _builtin_function_or_method_type)
+        or (ufunc is not None and isinstance(value, ufunc))
     ):
-        # Hydra can serialize dataclasses directly, thus we
-        # don't want to wrap these in `just`
         return just(value)
 
-    if ufunc is not None and isinstance(value, ufunc):
-        # ufuncs are weird.. they aren't classes and they aren't functions
-        return just(value)
-    return field(default=value)
+    return value
+
+
+def sanitized_default_value(value: Any) -> Union[Field, Type[Just]]:
+    if isinstance(value, _utils.KNOWN_MUTABLE_TYPES):
+        return mutable_value(value)
+    resolved_value = create_just_if_needed(value)
+    return field(default=value) if value is resolved_value else resolved_value
 
 
 # overloads when `hydra_partial=False`
 @overload
 def builds(
     target: Importable,
-    *,
+    *pos_args: Any,
     populate_full_signature: bool = False,
     hydra_partial: Literal[False] = False,
     hydra_recursive: bool = True,
@@ -315,7 +346,7 @@ def builds(
 @overload
 def builds(
     target: Importable,
-    *,
+    *pos_args: Any,
     populate_full_signature: bool = False,
     hydra_partial: Literal[True],
     hydra_recursive: bool = True,
@@ -332,7 +363,7 @@ def builds(
 @overload
 def builds(
     target: Importable,
-    *,
+    *pos_args: Any,
     populate_full_signature: bool = False,
     hydra_partial: bool,
     hydra_recursive: bool = True,
@@ -349,7 +380,7 @@ def builds(
 
 def builds(
     target: Importable,
-    *,
+    *pos_args: Any,
     populate_full_signature: bool = False,
     hydra_partial: bool = False,
     hydra_recursive: bool = True,
@@ -369,7 +400,10 @@ def builds(
     target : Union[Instantiable, Callable]
         The object to be instantiated/called
 
-    kwargs_for_target : Any
+    *pos_args: Any
+        Positional arguments passed to `target`
+
+    **kwargs_for_target : Any
         The keyword arguments passed to `target(...)`.
 
         The arguments specified here solely determine the fields and init-parameters of the
@@ -455,7 +489,7 @@ def builds(
 
     Examples
     --------
-    **Basic Usage**
+    Basic Usage:
 
     >>> from hydra_zen import builds, instantiate
     >>> builds(dict, a=1, b='x')  # makes a dataclass that will "build" a dictionary with the specified fields
@@ -463,6 +497,12 @@ def builds(
     >>> instantiate(builds(dict, a=1, b='x'))  # using hydra to build the dictionary
     {'a': 1, 'b': 'x'}
 
+    >>> Conf = builds(len, [1, 2, 3])  # specifying positional arguments
+    >>> Conf._args_
+    ([1, 2, 3],)
+    >>> instantiate(Conf)
+    3
+
     Using `builds` with partial instantiation
 
     >>> def a_two_tuple(x: int, y: float): return x, y
@@ -555,16 +595,27 @@ def builds(
 
     # `base_fields` stores the list of fields that will be present in our dataclass
     #
-    # Note: _args_ should be added here once hydra supports it in structured configs
     # TODO: should we always write these? Or is it more legible to only write them
     #       if they were explicitly specified by the user?
     #       - Presently we always need to write these, otherwise inheritance
     #         becomes an issue (as it is with _partial_target_
     base_fields: List[Tuple[str, type, Field_Entry]] = target_field + [
-        ("_recursive_", bool, field(default=hydra_recursive, init=False)),
-        ("_convert_", str, field(default=hydra_convert, init=False)),
+        (_RECURSIVE_FIELD_NAME, bool, field(default=hydra_recursive, init=False)),
+        (_CONVERT_FIELD_NAME, str, field(default=hydra_convert, init=False)),
     ]
 
+    if pos_args:
+        base_fields.append(
+            (
+                _POS_ARG_FIELD_NAME,
+                Tuple[Any, ...],
+                field(
+                    default=tuple(create_just_if_needed(x) for x in pos_args),
+                    init=False,
+                ),
+            )
+        )
+
     try:
         signature_params = inspect.signature(target).parameters
         target_has_valid_signature: bool = True
@@ -593,45 +644,114 @@ def builds(
         # Covers case for ufuncs, which do not have inspectable type hints
         type_hints = defaultdict(lambda: Any)
 
-    # True if **kwargs is in the signature of `target`
-    sig_has_var_kwargs = any(
-        p.kind is inspect.Parameter.VAR_KEYWORD for p in signature_params.values()
-    )
+    sig_by_kind: Dict[Any, List[inspect.Parameter]] = {
+        _POSITIONAL_ONLY: [],
+        _POSITIONAL_OR_KEYWORD: [],
+        _VAR_POSITIONAL: [],
+        _KEYWORD_ONLY: [],
+        _VAR_KEYWORD: [],
+    }
+
+    for p in signature_params.values():
+        sig_by_kind[p.kind].append(p)
 
     # these are the names of the only parameters in the signature of `target` that can
     # be referenced by name
-    nameable_params_in_sig = set(
+    nameable_params_in_sig: Set[str] = set(
         p.name
-        for p in signature_params.values()
-        if p.kind
-        in {inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY}
+        for p in chain(sig_by_kind[_POSITIONAL_OR_KEYWORD], sig_by_kind[_KEYWORD_ONLY])
     )
 
+    if not pos_args and builds_bases:
+        # pos_args is potentially inherited
+        for _base in builds_bases:
+            pos_args = getattr(_base, _POS_ARG_FIELD_NAME, ())
+            if pos_args:
+                break
+
     fields_set_by_bases: Set[str] = {
         _field.name
         for _base in builds_bases
         for _field in fields(_base)
-        if not _field.name.startswith("_")
+        if _field.name not in _HYDRA_FIELD_NAMES
     }
 
-    # Validate that user-specified arguments correspond to parameters in target's
-    # signature that can be specified by name
-    if target_has_valid_signature and not sig_has_var_kwargs:
-        if not set(kwargs_for_target) <= nameable_params_in_sig:
-            _unexpected = set(kwargs_for_target) - nameable_params_in_sig
-            raise TypeError(
-                _utils.building_error_prefix(target)
-                + f"The following unexpected keyword argument(s) was specified for {_utils.get_obj_path(target)} via `builds`: "
-                f"{', '.join(_unexpected)}"
-            )
-        if not fields_set_by_bases <= nameable_params_in_sig:
-            _unexpected = fields_set_by_bases - nameable_params_in_sig
-            raise TypeError(
-                _utils.building_error_prefix(target)
-                + f"The following unexpected keyword argument(s) for {_utils.get_obj_path(target)} "
-                f"was specified via inheritance from a base class: "
-                f"{', '.join(_unexpected)}"
+    # Validate that user-specified arguments satisfy target's signature.
+    # Should catch:
+    #    - bad parameter names
+    #    - too many parameters-by-position
+    #    - multiple values specified for parameter (via positional and by-name)
+    #
+    # We don't raise on an under-specified signature because it is possible that the
+    # resulting dataclass will simply be inherited from and extended.
+    # The issues we catch here cannot be fixed downstream.
+    if target_has_valid_signature:
+
+        if not sig_by_kind[_VAR_KEYWORD]:
+            # check for unexpected kwargs
+            if not set(kwargs_for_target) <= nameable_params_in_sig:
+                _unexpected = set(kwargs_for_target) - nameable_params_in_sig
+                raise TypeError(
+                    _utils.building_error_prefix(target)
+                    + f"The following unexpected keyword argument(s) was specified for {_utils.get_obj_path(target)} "
+                    f"via `builds`: {', '.join(_unexpected)}"
+                )
+            if not fields_set_by_bases <= nameable_params_in_sig:
+                _unexpected = fields_set_by_bases - nameable_params_in_sig
+                raise TypeError(
+                    _utils.building_error_prefix(target)
+                    + f"The following unexpected keyword argument(s) for {_utils.get_obj_path(target)} "
+                    f"was specified via inheritance from a base class: "
+                    f"{', '.join(_unexpected)}"
+                )
+
+        if pos_args:
+            named_args = set(kwargs_for_target).union(fields_set_by_bases)
+
+            # indicates that number of parameters that could be specified by name,
+            # but are specified by position
+            _num_nameable_args_by_position = max(
+                0, len(pos_args) - len(sig_by_kind[_POSITIONAL_ONLY])
             )
+            if named_args:
+                # check for multiple values for arg, specified both via positional and kwarg
+                # E.g.: def f(x, y): ...
+                # f(1, 2, y=3)  # multiple values for `y`
+                for param in sig_by_kind[_POSITIONAL_OR_KEYWORD][
+                    :_num_nameable_args_by_position
+                ]:
+                    if param.name in named_args:
+                        raise TypeError(
+                            _utils.building_error_prefix(target)
+                            + f"Multiple values for argument {param.name} were specified for "
+                            f"{_utils.get_obj_path(target)} via `builds`"
+                        )
+            if not sig_by_kind[
+                _VAR_POSITIONAL
+            ] and _num_nameable_args_by_position > len(
+                sig_by_kind[_POSITIONAL_OR_KEYWORD]
+            ):
+                # Too many positional args specified.
+                # E.g.: def f(x, y): ...
+                # f(1, 2, 3)
+                _num_positional = len(sig_by_kind[_POSITIONAL_ONLY]) + len(
+                    sig_by_kind[_POSITIONAL_OR_KEYWORD]
+                )
+                _num_with_default = sum(
+                    p.default is not inspect.Parameter.empty
+                    and p.kind is _POSITIONAL_OR_KEYWORD
+                    for p in signature_params.values()
+                )
+                _permissible = (
+                    f"{_num_positional}"
+                    if not _num_with_default
+                    else f"{_num_positional - _num_with_default} to {_num_positional}"
+                )
+                raise TypeError(
+                    _utils.building_error_prefix(target)
+                    + f"{_utils.get_obj_path(target)} takes {_permissible} positional args, but "
+                    f"{len(pos_args)} were specified via `builds`"
+                )
 
     # Create valid dataclass fields from the user-specified values
     #
@@ -640,7 +760,7 @@ def builds(
     #    and is resolved to one of the type-annotations supported by hydra if possible,
     #    otherwise, is Any
     #  - arg-value: mutable values are automatically specified using default-factory
-    user_specified_params: Dict[str, Tuple[str, type, Field]] = {
+    user_specified_named_params: Dict[str, Tuple[str, type, Field]] = {
         name: (
             name,
             _utils.sanitized_type(type_hints.get(name, Any)),
@@ -665,14 +785,23 @@ def builds(
 
         # we need to keep track of what user-specified params we have set
         _seen: Set[str] = set()
-        for param in signature_params.values():
+
+        for n, param in enumerate(signature_params.values()):
+            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
+                # with named params (including inherited params).
+                continue
+
             if param.name not in nameable_params_in_sig:
                 # parameter cannot be specified by name
                 continue
 
-            if param.name in user_specified_params:
+            if param.name in user_specified_named_params:
                 # user-specified parameter is preferred
-                _fields_with_default_values.append(user_specified_params[param.name])
+                _fields_with_default_values.append(
+                    user_specified_named_params[param.name]
+                )
                 _seen.add(param.name)
             elif param.name in fields_set_by_bases:
                 # don't populate a parameter that can be derived from a base
@@ -701,16 +830,16 @@ def builds(
 
         base_fields.extend(_fields_with_default_values)
 
-        if sig_has_var_kwargs:
+        if sig_by_kind[_VAR_KEYWORD]:
             # if the signature has **kwargs, then we need to add any user-specified
             # parameters that have not already been added
             base_fields.extend(
                 entry
-                for name, entry in user_specified_params.items()
+                for name, entry in user_specified_named_params.items()
                 if name not in _seen
             )
     else:
-        base_fields.extend(user_specified_params.values())
+        base_fields.extend(user_specified_named_params.values())
 
     if dataclass_name is None:
         if hydra_partial is False:


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -3,4 +3,6 @@
 
 import hypothesis.strategies as st
 
-valid_hydra_literals = st.sampled_from([0, 1.0, True, "x", ["a", 1], {1: 1}, {"a": 1}])
+valid_hydra_literals = st.sampled_from(
+    [0, 1.0, True, "x", None, ["a", 1], {1: 1}, {"a": 1}]
+)
diff --git a/tests/test_against_numpy.py b/tests/test_against_numpy.py
--- a/tests/test_against_numpy.py
+++ b/tests/test_against_numpy.py
@@ -80,3 +80,7 @@ def test_ufunc_as_default_value():
     conf = builds(f, populate_full_signature=True)
     to_yaml(conf)  # check serializability
     assert instantiate(conf) is np.add
+
+
+def test_ufunc_positional_args():
+    assert instantiate(builds(np.add, 1.0, 2.0)) == 3.0
diff --git a/tests/test_dataclass_semantics.py b/tests/test_dataclass_semantics.py
--- a/tests/test_dataclass_semantics.py
+++ b/tests/test_dataclass_semantics.py
@@ -67,6 +67,29 @@ def test_chain_builds_of_targets_with_common_interfaces(full_sig, partial):
     assert out == (1, 2, 3)
 
 
+@pytest.mark.parametrize("full_sig", [True, False])
+@pytest.mark.parametrize("partial", [True, False])
+def test_pos_args_with_inheritance(full_sig, partial):
+
+    conf_1 = builds(f_three_vars, 1, 2)
+    conf_2 = builds(
+        f_three_vars,
+        z=3,
+        hydra_partial=partial,
+        populate_full_signature=full_sig,
+        builds_bases=(conf_1,),
+    )
+
+    # checks subclass relationships
+    assert conf_2.__mro__[:2] == (conf_2, conf_1)
+
+    out = instantiate(conf_2)
+    if partial:
+        out = out()  # resolve partial
+
+    assert out == (1, 2, 3)
+
+
 def f_3(x):
     pass
 
diff --git a/tests/test_documented_examples.py b/tests/test_documented_examples.py
--- a/tests/test_documented_examples.py
+++ b/tests/test_documented_examples.py
@@ -1,5 +1,6 @@
 # Copyright (c) 2021 Massachusetts Institute of Technology
 # SPDX-License-Identifier: MIT
+import pytest
 
 from hydra_zen import builds, hydrated_dataclass, instantiate, just, to_yaml
 
@@ -30,6 +31,10 @@ def test_documented_builds_simple_roundtrip_example():
     assert {"a": 1, "b": "x"} == instantiate(builds(dict, a=1, b="x"))
 
 
+def test_documented_builds_pos_args_roundtrip_example():
+    assert [1, 2, 3] == instantiate(builds(list, (1, 2, 3)))
+
+
 def f():
     pass
 
@@ -38,17 +43,22 @@ class C:
     pass
 
 
-def func(a_class, a_list=[1, 2], a_func=f):
-    return a_class, a_list, a_func
+def func(a_class, a_list=[1, 2], a_func=f, a_builtin_func=len):
+    return a_class, a_list, a_func, a_builtin_func
 
 
-def test_auto_normalization_of_default_values():
-    conf = builds(func, a_class=C, populate_full_signature=True)
+@pytest.mark.parametrize("as_pos_arg", [True, False])
+def test_auto_normalization_of_default_values(as_pos_arg):
+    if as_pos_arg:
+        conf = builds(func, C, populate_full_signature=True)
+    else:
+        conf = builds(func, a_class=C, populate_full_signature=True)
     to_yaml(conf)  # raises if not serializable
-    a_class, a_list, a_func = instantiate(conf)
+    a_class, a_list, a_func, a_builtin_func = instantiate(conf)
     assert a_class is C
     assert a_list == [1, 2]
     assert a_func is f
+    assert a_builtin_func is len
 
 
 def test_nested_configs():
@@ -56,3 +66,10 @@ def test_nested_configs():
     assert d["x"] == dict(w=1)
     assert d["y"] is f
     assert d["z"] is f
+
+
+def test_nested_configs_pos_args():
+    x, y, z = instantiate(builds(list, (builds(dict, w=1), just(f), f)))
+    assert x == dict(w=1)
+    assert y is f
+    assert z is f
diff --git a/tests/test_hydra_behaviors.py b/tests/test_hydra_behaviors.py
--- a/tests/test_hydra_behaviors.py
+++ b/tests/test_hydra_behaviors.py
@@ -182,12 +182,13 @@ def g2(x: List[C]):
 
 
 def test_type_checking():
-    conf = builds(f, populate_full_signature=True)(
-        x=("hi",)
-    )  # violates type annotation
+    conf = builds(f, populate_full_signature=True)(x=("hi",))
     with pytest.raises(ValidationError):
         instantiate(conf)
 
+    # should be ok
+    instantiate(builds(f, populate_full_signature=True)(x=(1,)))
+
     # nested configs should get validated too
     with pytest.raises(ValidationError):
         instantiate(builds(g, x=builds(C, x="hi")))  # Invalid: `C.x` must be int
diff --git a/tests/test_hydrated_dataclass.py b/tests/test_hydrated_dataclass.py
--- a/tests/test_hydrated_dataclass.py
+++ b/tests/test_hydrated_dataclass.py
@@ -39,3 +39,11 @@ class Conf3(Conf2):
     assert issubclass(Conf3, Conf1)
     assert issubclass(Conf3, Conf2)
     assert instantiate(Conf3) == (1, 2, 3)
+
+
+def test_pos_args():
+    @hydrated_dataclass(f2, 1, 2)
+    class Conf:
+        z: int = 3
+
+    assert instantiate(Conf) == (1, 2, 3)
diff --git a/tests/test_roundtrips.py b/tests/test_roundtrips.py
--- a/tests/test_roundtrips.py
+++ b/tests/test_roundtrips.py
@@ -2,13 +2,14 @@
 # SPDX-License-Identifier: MIT
 
 import string
-from typing import Any, Dict
+from typing import Any, Dict, List
 
 import hypothesis.strategies as st
 import pytest
 from hypothesis import given
+from omegaconf import OmegaConf
 
-from hydra_zen import builds, instantiate, just
+from hydra_zen import builds, instantiate, just, to_yaml
 from tests import valid_hydra_literals
 
 arbitrary_kwargs = st.dictionaries(
@@ -21,6 +22,10 @@ def pass_through_kwargs(**kwargs):
     return kwargs
 
 
+def pass_through_args(*args):
+    return args
+
+
 @given(kwargs=arbitrary_kwargs, full_sig=st.booleans())
 def test_builds_roundtrip(kwargs, full_sig: bool):
     assert kwargs == instantiate(
@@ -33,7 +38,7 @@ def test_builds_roundtrip(kwargs, full_sig: bool):
     call_kwargs=arbitrary_kwargs,
     full_sig=st.booleans(),
 )
-def test_builds_roundtrip_with_partial(
+def test_builds_kwargs_roundtrip_with_partial(
     partial_kwargs: Dict[str, Any],
     call_kwargs: Dict[str, Any],
     full_sig: bool,
@@ -51,23 +56,52 @@ def test_builds_roundtrip_with_partial(
     assert expected_kwargs == partial_struct(**call_kwargs)  # resolve partial
 
 
+@given(
+    partial_args=arbitrary_kwargs.map(lambda x: list(x.values())),
+    call_args=arbitrary_kwargs.map(lambda x: list(x.values())),
+    full_sig=st.booleans(),
+)
+def test_builds_args_roundtrip_with_partial(
+    partial_args: List[Any],
+    call_args: List[Any],
+    full_sig: bool,
+):
+    partial_struct = instantiate(
+        builds(
+            pass_through_args,
+            hydra_partial=True,
+            populate_full_signature=full_sig,
+            *partial_args,
+        ),
+    )
+
+    expected_args = partial_args.copy()
+    expected_args.extend(call_args)
+    assert tuple(expected_args) == partial_struct(*call_args)  # resolve partial
+
+
 def f(x, y=dict(a=2)):
     return x, y
 
 
 @pytest.mark.parametrize("full_sig", [True, False])
 @pytest.mark.parametrize("partial", [True, False])
-def test_builds_roundtrips_with_mutable_values(full_sig: bool, partial: bool):
+@pytest.mark.parametrize("named_arg", [True, False])
+def test_builds_roundtrips_with_mutable_values(
+    full_sig: bool, partial: bool, named_arg: bool
+):
     # tests mutable user-specified value and default value
-    result = instantiate(
-        builds(f, x=[1], populate_full_signature=full_sig, hydra_partial=partial)
-    )
+    if named_arg:
+        result = instantiate(
+            builds(f, x=[1], populate_full_signature=full_sig, hydra_partial=partial)
+        )
+    else:
+        result = instantiate(
+            builds(f, [1], populate_full_signature=full_sig, hydra_partial=partial)
+        )
     if partial:
         result = result()
-    assert result == (
-        [1],
-        dict(a=2),
-    )
+    assert result == ([1], {"a": 2})
 
 
 class LocalClass:
@@ -91,4 +125,6 @@ def local_function():
     ],
 )
 def test_just_roundtrip(obj):
-    assert instantiate(just(obj)) is obj
+    cfg = just(obj)
+    assert instantiate(cfg) is obj
+    assert instantiate(OmegaConf.create(to_yaml(cfg))) is obj
diff --git a/tests/test_signature_parsing.py b/tests/test_signature_parsing.py
--- a/tests/test_signature_parsing.py
+++ b/tests/test_signature_parsing.py
@@ -11,7 +11,7 @@
 import pytest
 from hypothesis import given, settings
 
-from hydra_zen import builds, hydrated_dataclass, mutable_value
+from hydra_zen import builds, hydrated_dataclass, instantiate, mutable_value, to_yaml
 from hydra_zen.typing import Just
 from tests import valid_hydra_literals
 
@@ -53,11 +53,25 @@ def f2(x, y, z, has_default=101):
     user_value_x=valid_hydra_literals,
     user_value_y=valid_hydra_literals,
     user_value_z=valid_hydra_literals,
-    specified_as_default=st.lists(st.sampled_from(["x", "y", "z"]), unique=True),
+    num_positional=st.integers(0, 3),
+    data=st.data(),
 )
 def test_builds_signature_shuffling_takes_least_path(
-    user_value_x, user_value_y, user_value_z, specified_as_default
+    user_value_x,
+    user_value_y,
+    user_value_z,
+    num_positional: int,
+    data: st.DataObject,
 ):
+    specified_as_positional = set(["x", "y", "z"][:num_positional])
+
+    if num_positional < 3:
+        specified_as_default = data.draw(
+            st.lists(st.sampled_from(["x", "y", "z"][num_positional:]), unique=True),
+            label="specified_as_default",
+        )
+    else:
+        specified_as_default = data.draw(st.just([]), label="specified_as_default")
 
     # We will specify an arbitrary selection of x, y, z via `builds`, and then specify the
     # remaining parameters via initializing the resulting dataclass. This ensures that we can
@@ -67,29 +81,43 @@ def test_builds_signature_shuffling_takes_least_path(
     # E.g.
     #  - `builds(f, populate_full_signature=True)`.__init__ -> (x, y, z, has_default=default_value)
     #  - `builds(f, x=1, populate_full_signature=True)`.__init__ -> (y, z, x=1, has_default=default_value)
-    #  - `builds(f, y=2, z=-1, populate_full_signature=True)`.__init__ -> (z, y=2, z=-1, has_default=default_value)
+    #  - `builds(f, y=2, z=-1, populate_full_signature=True)`.__init__ -> (x, y=2, z=-1, has_default=default_value)
+    #  - `builds(f, 1, 2, populate_full_signature=True)`.__init__ -> (z=-1, has_default=default_value)
 
     defaults = dict(x=user_value_x, y=user_value_y, z=user_value_z)
 
     default_override = {k: defaults[k] for k in specified_as_default}
+    positional = [defaults[k] for k in sorted(specified_as_positional)]
+
     specified_via_init = {
-        k: defaults[k] for k in set(defaults) - set(specified_as_default)
+        k: defaults[k]
+        for k in set(defaults) - set(specified_as_default) - specified_as_positional
     }
 
-    BuildsF = builds(f2, **default_override, populate_full_signature=True)
+    BuildsF = builds(f2, *positional, **default_override, populate_full_signature=True)
     sig_param_names = [p.name for p in inspect.signature(BuildsF).parameters.values()]
     expected_param_ordering = (
         sorted(specified_via_init) + sorted(specified_as_default) + ["has_default"]
     )
+    expected_param_ordering = [
+        p for p in expected_param_ordering if p not in specified_as_positional
+    ]
 
     assert sig_param_names == expected_param_ordering
 
     b = BuildsF(**specified_via_init)
-    assert b.x == user_value_x
-    assert b.y == user_value_y
-    assert b.z == user_value_z
+    assert "x" in specified_as_positional and not hasattr(b, "x") or b.x == user_value_x
+    assert "y" in specified_as_positional and not hasattr(b, "y") or b.y == user_value_y
+    assert "z" in specified_as_positional and not hasattr(b, "z") or b.z == user_value_z
     assert b.has_default == 101
 
+    to_yaml(b)  # should never crash
+    x, y, z, has_default = instantiate(b)  # should never crash
+    assert x == user_value_x
+    assert y == user_value_y
+    assert z == user_value_z
+    assert has_default == 101
+
 
 def f3(x: str, *args, y: int = 22, z=[2], **kwargs):
     pass
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -87,7 +87,7 @@ def test_documented_instantiate_example():
     from hydra_zen import builds, instantiate
 
     assert instantiate(builds(dict, a=1, b=2), c=3) == dict(a=1, b=2, c=3)
-    assert instantiate(builds(list), (1, 2, 3)) == [1, 2, 3]
+    assert instantiate(builds(list, (1, 2, 3))) == [1, 2, 3]
 
 
 class Color(enum.Enum):
diff --git a/tests/test_validation.py b/tests/test_validation.py
--- a/tests/test_validation.py
+++ b/tests/test_validation.py
@@ -29,23 +29,86 @@ def test_builds_hydra_partial_raises_if_recursion_disabled():
         builds(dict, hydra_partial=True, hydra_recursive=False)
 
 
-def x_is_var_pos(*x):
+def f_starx(*x):
     pass
 
 
-def x_is_not_in_sig(y):
+def f_kwargs(**kwargs):
     pass
 
 
-def sig_is_empty():
+def f_y(y):
     pass
 
 
-@pytest.mark.parametrize("func", [x_is_var_pos, x_is_not_in_sig, sig_is_empty])
+def f_empty():
+    pass
+
+
+def f_x_y2_str_z3(x, y=2, *, z=3):
+    pass
+
+
+@pytest.mark.parametrize(
+    "func, args, kwargs",
+    [
+        # named param not in sig
+        (f_starx, (), dict(x=10)),
+        (f_starx, (), dict(y=10)),
+        (f_y, (), dict(x=10)),
+        (f_empty, (), dict(x=10)),
+        # too many pos args
+        (f_kwargs, (1, 2), dict(y=2)),
+        (f_x_y2_str_z3, (1, 2, 3), {}),
+        (f_empty, (1,), {}),
+        (f_y, (1, 2), {}),
+        # multiple values specified for param
+        (f_y, (1,), dict(y=1)),
+        (
+            f_x_y2_str_z3,
+            (1, 2),
+            dict(y=1, z=4),
+        ),
+    ],
+)
 @given(partial=st.booleans(), full_sig=st.booleans())
-def test_builds_raises_when_user_specified_arg_is_not_in_sig(func, full_sig, partial):
+def test_builds_raises_when_user_specified_args_violate_sig(
+    func, args, kwargs, full_sig, partial
+):
+    with pytest.raises(TypeError):
+        builds(
+            func,
+            *args,
+            **kwargs,
+            hydra_partial=partial,
+            populate_full_signature=full_sig
+        )
+
+    # test when **kwargs are inherited
     with pytest.raises(TypeError):
-        builds(func, x=10, hydra_partial=partial, populate_full_signature=full_sig)
+        kwarg_base = builds(
+            func, **kwargs, hydra_partial=partial, populate_full_signature=full_sig
+        )
+        builds(
+            func,
+            *args,
+            hydra_partial=partial,
+            populate_full_signature=full_sig,
+            builds_bases=(kwarg_base,)
+        )
+
+    # test when *args are inherited
+    with pytest.raises(TypeError):
+        args_base = builds(
+            func, *args, hydra_partial=partial, populate_full_signature=full_sig
+        )
+        builds(
+            func,
+            **kwargs,
+            hydra_partial=partial,
+            populate_full_signature=full_sig,
+            builds_bases=(args_base,)
+        )
 
 
 @dataclass
@@ -78,17 +141,17 @@ def test_builds_raises_when_base_has_invalid_arg(full_sig, partial):
         Counter,
         zip_longest,
         dataclass,
-        x_is_var_pos,
-        sig_is_empty,
+        f_starx,
+        f_empty,
         given,
         assume,
         hydrated_dataclass,
         inspect.signature,
     ],
 )
-@given(partial=st.booleans(), full_sig=st.booleans())
+@given(full_sig=st.booleans())
 def test_fuzz_build_validation_against_a_bunch_of_common_objects(
-    target, partial: bool, full_sig: bool
+    target, full_sig: bool
 ):
     doesnt_have_sig = False
     try:
@@ -99,12 +162,10 @@ def test_fuzz_build_validation_against_a_bunch_of_common_objects(
     if doesnt_have_sig and full_sig:
         assume(False)
 
-    conf = builds(target, hydra_partial=partial, populate_full_signature=full_sig)
+    conf = builds(target, hydra_partial=True, populate_full_signature=full_sig)
 
     OmegaConf.create(to_yaml(conf))  # ensure serializable
-
-    if partial:
-        instantiate(conf)  # ensure instantiable
+    instantiate(conf)  # ensure instantiable
 
 
 def f2():
diff --git a/tests/test_validation_py38.py b/tests/test_validation_py38.py
--- a/tests/test_validation_py38.py
+++ b/tests/test_validation_py38.py
@@ -1,19 +1,64 @@
 # Copyright (c) 2021 Massachusetts Institute of Technology
 # SPDX-License-Identifier: MIT
 
+import inspect
+
 import hypothesis.strategies as st
 import pytest
 from hypothesis import given
+from omegaconf import OmegaConf
 
-from hydra_zen import builds
+from hydra_zen import builds, instantiate, to_yaml
+from tests import valid_hydra_literals
 
 
 def x_is_pos_only(x, /):
-    pass
+    return x
+
+
+def xy_are_pos_only(x, y, /):
+    return x, y
 
 
 @pytest.mark.parametrize("func", [x_is_pos_only])
 @given(partial=st.booleans(), full_sig=st.booleans())
-def test_builds_raises_when_user_specified_arg_is_not_in_sig(func, full_sig, partial):
+def test_builds_runtime_validation_pos_only_not_nameable(func, full_sig, partial):
     with pytest.raises(TypeError):
         builds(func, x=10, hydra_partial=partial, populate_full_signature=full_sig)
+
+
+@given(
+    x=valid_hydra_literals,
+    full_sig=st.booleans(),
+    partial=st.booleans(),
+)
+def test_roundtrip_pos_only(x, full_sig: bool, partial: bool):
+    cfg = builds(
+        x_is_pos_only, x, populate_full_signature=full_sig, hydra_partial=partial
+    )
+
+    out = instantiate(cfg)
+
+    assert len(inspect.signature(cfg).parameters) == 0
+
+    if partial:
+        out = out()
+    assert out == x
+
+    out = instantiate(OmegaConf.create(to_yaml(cfg)))
+    if partial:
+        out = out()
+    assert out == x
+
+
+@given(full_sig=st.booleans())
+def test_pos_only_with_partial_is_not_required(full_sig):
+    cfg = builds(x_is_pos_only, populate_full_signature=full_sig, hydra_partial=True)
+    assert instantiate(cfg)(1) == 1
+    assert len(inspect.signature(cfg).parameters) == 0
+
+    cfg = builds(
+        xy_are_pos_only, 1, populate_full_signature=full_sig, hydra_partial=True
+    )
+    assert instantiate(cfg)(2) == (1, 2)
+    assert len(inspect.signature(cfg).parameters) == 0