Skip to content

Commit

Permalink
Support partial'd target in builds (#199)
Browse files Browse the repository at this point in the history
* support partial'd targets in builds

* include Partial in supported primitive types

* increase num examples in test

* add pyright tests

* update changelog

* update docs

* BUGFIX: accidentally deleted critical code-block when merging in from other branch
  • Loading branch information
rsokl authored Jan 13, 2022
1 parent 764eee6 commit 5d30544
Show file tree
Hide file tree
Showing 6 changed files with 106 additions and 29 deletions.
4 changes: 3 additions & 1 deletion docs/source/api_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
*****************************************

Expand All @@ -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`
Expand Down
13 changes: 10 additions & 3 deletions docs/source/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
------------
Expand Down
74 changes: 49 additions & 25 deletions src/hydra_zen/structured_configs/_implementations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand Down Expand Up @@ -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
----------
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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**
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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**
Expand Down Expand Up @@ -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)
Expand All @@ -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**
Expand All @@ -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>
Expand Down
1 change: 1 addition & 0 deletions src/hydra_zen/typing/_implementations.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ class HasPartialTarget(Protocol): # pragma: no cover
_DataClass,
complex,
Path,
Partial,
range,
set,
EmptyDict, # not covered by Mapping[..., ...]
Expand Down
12 changes: 12 additions & 0 deletions tests/annotations/declarations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions tests/test_value_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

0 comments on commit 5d30544

Please sign in to comment.