Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/source/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ Changelog
This is a record of all past hydra-zen releases and what went into them, in reverse
chronological order. All previous releases should still be available on pip.

.. _v0.16.1:

-------------------
0.16.1 - 2026-06-23
-------------------

- Fixes a bug where :func:`~hydra_zen.just` would fail to convert a dataclass to a config when one of its fields is annotated with a container of a dataclass type (e.g. ``dict[str, SomeDataclass]`` or ``list[SomeDataclass]``). Previously this raised an OmegaConf ``ValidationError`` during ``to_yaml``. See :issue:`858`.

.. _v0.16.0:

-------------------
Expand Down
40 changes: 39 additions & 1 deletion src/hydra_zen/structured_configs/_implementations.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,34 @@ def _patched_yaml_loader(*args: Any, **kwargs: Any) -> Any: # pragma: no cover
_omegaconf_utils.get_yaml_loader = _patched_yaml_loader


def _value_contains_builds(value: Any) -> bool:
# Returns `True` if `value` is a list/tuple/mapping that contains
# (possibly nested) a structured config as an element/value.
#
# Such a config will not be a subclass of the element-type specified by a
# container annotation (e.g. a `Builds_Inner` config vs. a `dict[str, Inner]`
# annotation), so OmegaConf's type-checking would reject it. See:
# https://github.com/mit-ll-responsible-ai/hydra-zen/issues/858
if isinstance(value, Mapping):
return any(_value_contains_builds(v) for v in value.values())
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
return any(_value_contains_builds(v) for v in value)
return is_builds(value)


def _resolve_field_default(field_obj: Any) -> Any:
# Returns a field's default value, resolving its `default_factory` when present.
# dataclasses store mutable defaults (e.g. lists/dicts) via `default_factory`,
# so this is needed to inspect the actual container value -- e.g. to detect a
# nested structured config. Returns `MISSING` when there is no default.
default = safe_getattr(field_obj, "default", MISSING)
if default is MISSING:
factory = safe_getattr(field_obj, "default_factory", MISSING)
if factory is not MISSING and factory is not None:
return factory()
return default


def _retain_type_info(type_: type, value: Any, hydra_recursive: Optional[bool]):
# OmegaConf's type-checking occurs before instantiation occurs.
# This means that, e.g., passing `Builds[int]` to a field `x: int`
Expand All @@ -228,6 +256,12 @@ def _retain_type_info(type_: type, value: Any, hydra_recursive: Optional[bool]):
# an interpolated field may resolve to a structured conf, which may
# instantiate to a value of the specified type
return False
elif _value_contains_builds(value):
# `value` is a container (e.g. list/dict) holding a structured config;
# broaden the annotation so OmegaConf doesn't type-check the config
# against the container's element type. See:
# https://github.com/mit-ll-responsible-ai/hydra-zen/issues/858
return False
return True
elif is_builds(type_):
return True
Expand Down Expand Up @@ -2962,6 +2996,10 @@ def builds(self,target, populate_full_signature=False, **kw):
# If `.default` is not set, then `value` is a Hydra-supported mutable
# value, and thus it is "sanitized"
sanitized_value = safe_getattr(_field, "default", value)
if sanitized_value is MISSING:
# mutable defaults (e.g. lists/dicts) are stored via
# `default_factory`; resolve it so the value can be inspected
sanitized_value = _resolve_field_default(_field)
sanitized_type = (
cls._sanitized_type(type_, wrap_optional=sanitized_value is None)
if _retain_type_info(
Expand Down Expand Up @@ -3290,7 +3328,7 @@ def make_config(
f.hint
if _retain_type_info(
type_=f.hint,
value=f.default.default,
value=_resolve_field_default(f.default),
hydra_recursive=hydra_recursive,
)
else Any
Expand Down
66 changes: 66 additions & 0 deletions tests/test_dataclass_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
mutable_value,
)
from hydra_zen.errors import HydraZenUnsupportedPrimitiveError
from hydra_zen.structured_configs._implementations import (
_resolve_field_default,
_value_contains_builds,
)
from hydra_zen.structured_configs._type_guards import is_builds
from hydra_zen.typing import Builds
from hydra_zen.typing._implementations import DataClass_, InstOrType, ZenConvert
Expand Down Expand Up @@ -130,6 +134,68 @@ def test_recursive_conversion(x):
assert instantiate(OmegaConf.create(OmegaConf.to_yaml(just(x)))) == x


@dataclass
class _Inner858:
x: int = 0


@dataclass
class _OuterDict858:
outer: "dict[str, _Inner858]"


@dataclass
class _OuterList858:
outer: "list[_Inner858]"


@dataclass
class _OuterNested858:
outer: "dict[str, list[_Inner858]]"


@pytest.mark.parametrize(
"obj",
[
_OuterDict858(outer={"a": _Inner858(x=1)}),
_OuterList858(outer=[_Inner858(x=1)]),
_OuterNested858(outer={"a": [_Inner858(x=1), _Inner858(x=2)]}),
],
)
def test_just_on_container_field_of_typed_dataclasses(obj):
# https://github.com/mit-ll-responsible-ai/hydra-zen/issues/858
# A dataclass whose field is annotated with a container of a dataclass type
# (e.g. `dict[str, Inner]`) used to fail to convert to yaml because the field's
# values get converted to `Builds_Inner` configs while the field's annotation
# retained the original (non-builds) element type, which OmegaConf rejects.
Conf = just(obj)
# must not raise omegaconf.errors.ValidationError
assert instantiate(OmegaConf.create(OmegaConf.to_yaml(Conf))) == obj


def test_value_contains_builds():
# https://github.com/mit-ll-responsible-ai/hydra-zen/issues/858
cfg = just(_Inner858(x=1))
assert is_builds(cfg)
assert _value_contains_builds(cfg) is True
assert _value_contains_builds([0, cfg]) is True
assert _value_contains_builds({"a": cfg}) is True
assert _value_contains_builds({"a": [cfg]}) is True
assert _value_contains_builds([1, 2, 3]) is False
assert _value_contains_builds({"a": 1}) is False
assert _value_contains_builds("not a container") is False


def test_resolve_field_default():
# https://github.com/mit-ll-responsible-ai/hydra-zen/issues/858
# plain default
assert _resolve_field_default(field(default=1)) == 1
# default_factory is resolved to its produced value
assert _resolve_field_default(field(default_factory=lambda: [1, 2])) == [1, 2]
# no default and no factory -> MISSING
assert _resolve_field_default(field()) is MISSING


@dataclass
class NoDefault:
x: Any
Expand Down
Loading