diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 944307ee9..54cdd7b6e 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -771,12 +771,25 @@ If resolve is set to True, interpolations will be resolved during conversion. >>> show(resolved) type: dict, value: {'foo': 'bar', 'foo2': 'bar'} + +Using ``structured_config_mode`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You can customize the treatment of ``OmegaConf.to_container()`` for Structured Config nodes using the ``structured_config_mode`` option. By default, Structured Config nodes are converted to plain dict. + Using ``structured_config_mode=SCMode.DICT_CONFIG`` causes such nodes to remain as DictConfig, allowing attribute style access on the resulting node. +Using ``structured_config_mode=SCMode.INSTANTIATE``, Structured Config nodes +are converted to instances of the backing dataclass or attrs class. Note that +when ``structured_config_mode=SCMode.INSTANTIATE``, interpolations nested within +a structured config node will be resolved, even if ``OmegaConf.to_container`` is called +with the the keyword argument ``resolve=False``, so that interpolations are resolved before +being used to instantiate dataclass/attr class instances. Interpolations within +non-structured parent nodes will be resolved (or not) as usual, according to +the ``resolve`` keyword arg. + .. doctest:: >>> from omegaconf import SCMode @@ -788,6 +801,30 @@ as DictConfig, allowing attribute style access on the resulting node. >>> show(container["structured_config"]) type: DictConfig, value: {'port': 80, 'host': 'localhost'} +OmegaConf.to_object +^^^^^^^^^^^^^^^^^^^^^^ +The ``OmegaConf.to_object`` method recursively converts DictConfig and ListConfig objects +into dicts and lists, with the exception that Structured Config objects are +converted into instances of the backing dataclass or attr class. All OmegaConf +interpolations are resolved before conversion to Python containers. + +.. doctest:: + + >>> container = OmegaConf.to_object(conf) + >>> show(container) + type: dict, value: {'structured_config': MyConfig(port=80, host='localhost')} + >>> show(container["structured_config"]) + type: MyConfig, value: MyConfig(port=80, host='localhost') + +Note that here, ``container["structured_config"]`` is actually an instance of +``MyConfig``, whereas in the previous examples we had a ``dict`` or a +``DictConfig`` object that was duck-typed to look like an instance of +``MyConfig``. + +The call ``OmegaConf.to_object(conf)`` is equivalent to +``OmegaConf.to_container(conf, resolve=True, +structured_config_mode=SCMode.INSTANTIATE)``. + OmegaConf.resolve ^^^^^^^^^^^^^^^^^ .. code-block:: python diff --git a/news/472.feature b/news/472.feature new file mode 100644 index 000000000..c51b70e8d --- /dev/null +++ b/news/472.feature @@ -0,0 +1 @@ +Add the OmegaConf.to_object method, which converts Structured Configs to native instances of the underlying `@dataclass` or `@attr.s` class. diff --git a/omegaconf/_utils.py b/omegaconf/_utils.py index 6c8585a5c..9ba83ff8b 100644 --- a/omegaconf/_utils.py +++ b/omegaconf/_utils.py @@ -201,6 +201,12 @@ def _resolve_forward(type_: Type[Any], module: str) -> Type[Any]: return type_ +def get_attr_class_field_names(obj: Any) -> List[str]: + is_type = isinstance(obj, type) + obj_type = obj if is_type else type(obj) + return list(attr.fields_dict(obj_type)) + + def get_attr_data(obj: Any, allow_objects: Optional[bool] = None) -> Dict[str, Any]: from omegaconf.omegaconf import OmegaConf, _maybe_wrap @@ -240,6 +246,10 @@ def get_attr_data(obj: Any, allow_objects: Optional[bool] = None) -> Dict[str, A return d +def get_dataclass_field_names(obj: Any) -> List[str]: + return [field.name for field in dataclasses.fields(obj)] + + def get_dataclass_data( obj: Any, allow_objects: Optional[bool] = None ) -> Dict[str, Any]: @@ -332,6 +342,15 @@ def is_structured_config_frozen(obj: Any) -> bool: return False +def get_structured_config_field_names(obj: Any) -> List[str]: + if is_dataclass(obj): + return get_dataclass_field_names(obj) + elif is_attr_class(obj): + return get_attr_class_field_names(obj) + else: + raise ValueError(f"Unsupported type: {type(obj).__name__}") + + def get_structured_config_data( obj: Any, allow_objects: Optional[bool] = None ) -> Dict[str, Any]: diff --git a/omegaconf/base.py b/omegaconf/base.py index ffa35a01c..b1ffc3419 100644 --- a/omegaconf/base.py +++ b/omegaconf/base.py @@ -728,5 +728,6 @@ def _has_ref_type(self) -> bool: class SCMode(Enum): - DICT = 1 # convert to plain dict + DICT = 1 # Convert to plain dict DICT_CONFIG = 2 # Keep as OmegaConf DictConfig + INSTANTIATE = 3 # Create a dataclass or attrs class instance diff --git a/omegaconf/basecontainer.py b/omegaconf/basecontainer.py index bb2f612cf..3446b31a8 100644 --- a/omegaconf/basecontainer.py +++ b/omegaconf/basecontainer.py @@ -213,6 +213,10 @@ def convert(val: Node) -> Any: and structured_config_mode == SCMode.DICT_CONFIG ): return conf + if structured_config_mode == SCMode.INSTANTIATE and is_structured_config( + conf._metadata.object_type + ): + return conf._to_object() retdict: Dict[str, Any] = {} for key in conf.keys(): diff --git a/omegaconf/dictconfig.py b/omegaconf/dictconfig.py index 9f418d57f..b868bcb7a 100644 --- a/omegaconf/dictconfig.py +++ b/omegaconf/dictconfig.py @@ -20,11 +20,13 @@ ValueKind, _get_value, _is_interpolation, + _is_missing_literal, _is_missing_value, _is_none, _valid_dict_key_annotation_type, format_and_raise, get_structured_config_data, + get_structured_config_field_names, get_type_of, get_value_kind, is_container_annotation, @@ -35,7 +37,7 @@ type_str, valid_value_annotation_type, ) -from .base import Container, ContainerMetadata, DictKeyType, Node +from .base import Container, ContainerMetadata, DictKeyType, Node, SCMode from .basecontainer import BaseContainer from .errors import ( ConfigAttributeError, @@ -682,3 +684,48 @@ def _dict_conf_eq(d1: "DictConfig", d2: "DictConfig") -> bool: return False return True + + def _to_object(self) -> Any: + """ + Instantiate an instance of `self._metadata.object_type`. + This requires `self` to be a structured config. + Nested subconfigs are converted to_container with resolve=True. + """ + object_type = self._metadata.object_type + assert is_structured_config(object_type) + object_type_field_names = set(get_structured_config_field_names(object_type)) + + field_items: Dict[str, Any] = {} + nonfield_items: Dict[str, Any] = {} + for k in self.keys(): + node = self._get_node(k) + assert isinstance(node, Node) + node = node._dereference_node(throw_on_resolution_failure=True) + assert node is not None + if isinstance(node, Container): + v = BaseContainer._to_content( + node, + resolve=True, + enum_to_str=False, + structured_config_mode=SCMode.INSTANTIATE, + ) + else: + v = node._value() + + if _is_missing_literal(v): + self._format_and_raise( + key=k, + value=None, + cause=MissingMandatoryValue( + "Structured config of type `$OBJECT_TYPE` has missing mandatory value: $KEY" + ), + ) + if k in object_type_field_names: + field_items[k] = v + else: + nonfield_items[k] = v + + result = object_type(**field_items) + for k, v in nonfield_items.items(): + setattr(result, k, v) + return result diff --git a/omegaconf/omegaconf.py b/omegaconf/omegaconf.py index bad25b392..d49195a5c 100644 --- a/omegaconf/omegaconf.py +++ b/omegaconf/omegaconf.py @@ -580,6 +580,9 @@ def to_container( :param structured_config_mode: Specify how Structured Configs (DictConfigs backed by a dataclass) are handled. By default (`structured_config_mode=SCMode.DICT`) structured configs are converted to plain dicts. If `structured_config_mode=SCMode.DICT_CONFIG`, structured config nodes will remain as DictConfig. + If `structured_config_mode=SCMode.INSTANTIATE`, this function will instantiate structured configs + (DictConfigs backed by a dataclass), by creating an instance of the underlying dataclass. + See also OmegaConf.to_object. :return: A dict or a list representing this config as a primitive container. """ if not OmegaConf.is_config(cfg): @@ -594,6 +597,30 @@ def to_container( structured_config_mode=structured_config_mode, ) + @staticmethod + def to_object( + cfg: Any, + *, + enum_to_str: bool = False, + ) -> Union[Dict[DictKeyType, Any], List[Any], None, str, Any]: + """ + Resursively converts an OmegaConf config to a primitive container (dict or list). + Any DictConfig objects backed by dataclasses or attrs classes are instantiated + as instances of those backing classes. + + This is an alias for OmegaConf.to_container(..., resolve=True, structured_config_mode=SCMode.INSTANTIATE) + + :param cfg: the config to convert + :param enum_to_str: True to convert Enum values to strings + :return: A dict or a list or dataclass representing this config. + """ + return OmegaConf.to_container( + cfg=cfg, + resolve=True, + enum_to_str=enum_to_str, + structured_config_mode=SCMode.INSTANTIATE, + ) + @staticmethod def is_missing(cfg: Any, key: DictKeyType) -> bool: assert isinstance(cfg, Container) diff --git a/tests/structured_conf/data/attr_classes.py b/tests/structured_conf/data/attr_classes.py index 2cd4f6edf..fc7cf3d5c 100644 --- a/tests/structured_conf/data/attr_classes.py +++ b/tests/structured_conf/data/attr_classes.py @@ -238,6 +238,14 @@ class Interpolation: z2: str = SI("${x}_${y}") +@attr.s(auto_attribs=True) +class RelativeInterpolation: + x: int = 100 + y: int = 200 + z1: int = II(".x") + z2: str = SI("${.x}_${.y}") + + @attr.s(auto_attribs=True) class BoolOptional: with_default: Optional[bool] = True @@ -440,6 +448,10 @@ class Str2StrWithField(Dict[str, str]): class Str2IntWithStrField(Dict[str, int]): foo: int = 1 + @attr.s(auto_attribs=True) + class Str2UserWithField(Dict[str, User]): + foo: User = User("Bond", 7) + class Error: @attr.s(auto_attribs=True) class User2Str(Dict[User, str]): diff --git a/tests/structured_conf/data/dataclasses.py b/tests/structured_conf/data/dataclasses.py index c66257b9d..ef5ccf69d 100644 --- a/tests/structured_conf/data/dataclasses.py +++ b/tests/structured_conf/data/dataclasses.py @@ -239,6 +239,14 @@ class Interpolation: z2: str = SI("${x}_${y}") +@dataclass +class RelativeInterpolation: + x: int = 100 + y: int = 200 + z1: int = II(".x") + z2: str = SI("${.x}_${.y}") + + @dataclass class BoolOptional: with_default: Optional[bool] = True @@ -461,6 +469,10 @@ class Str2StrWithField(Dict[str, str]): class Str2IntWithStrField(Dict[str, int]): foo: int = 1 + @dataclass + class Str2UserWithField(Dict[str, User]): + foo: User = User("Bond", 7) + class Error: @dataclass class User2Str(Dict[User, str]): diff --git a/tests/test_errors.py b/tests/test_errors.py index cd91118b0..c7d17f703 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1234,6 +1234,18 @@ def finalize(self, cfg: Any) -> None: ), id="list,readonly:del", ), + # to_object + param( + Expected( + create=lambda: OmegaConf.structured(User), + op=lambda cfg: OmegaConf.to_object(cfg), + exception_type=MissingMandatoryValue, + msg="Structured config of type `User` has missing mandatory value: name", + key="name", + child_node=lambda cfg: cfg._get_node("name"), + ), + id="to_object:structured-missing-field", + ), ] diff --git a/tests/test_to_container.py b/tests/test_to_container.py index 60b4a8dc6..fee18be83 100644 --- a/tests/test_to_container.py +++ b/tests/test_to_container.py @@ -1,10 +1,19 @@ import re from enum import Enum -from typing import Any, Dict, List +from importlib import import_module +from typing import Any, Dict, List, Optional from pytest import fixture, mark, param, raises -from omegaconf import DictConfig, ListConfig, OmegaConf, SCMode +from omegaconf import ( + DictConfig, + ListConfig, + MissingMandatoryValue, + OmegaConf, + SCMode, + open_dict, +) +from omegaconf.errors import InterpolationResolutionError from tests import Color, User @@ -39,49 +48,88 @@ def assert_container_with_primitives(item: Any) -> None: @mark.parametrize( - "src,ex_dict,ex_dict_config,key", + "structured_config_mode,src,expected,key,expected_value_type", [ param( + SCMode.DICT, {"user": User(age=7, name="Bond")}, {"user": {"name": "Bond", "age": 7}}, - {"user": User(age=7, name="Bond")}, "user", - id="structured-inside-dict", + dict, + id="DICT-dict", ), param( + SCMode.DICT, [1, User(age=7, name="Bond")], [1, {"name": "Bond", "age": 7}], + 1, + dict, + id="DICT-list", + ), + param( + SCMode.DICT_CONFIG, + {"user": User(age=7, name="Bond")}, + {"user": User(age=7, name="Bond")}, + "user", + DictConfig, + id="DICT_CONFIG-dict", + ), + param( + SCMode.DICT_CONFIG, + [1, User(age=7, name="Bond")], + [1, User(age=7, name="Bond")], + 1, + DictConfig, + id="DICT_CONFIG-list", + ), + param( + SCMode.INSTANTIATE, + {"user": User(age=7, name="Bond")}, + {"user": User(age=7, name="Bond")}, + "user", + User, + id="INSTANTIATE-dict", + ), + param( + SCMode.INSTANTIATE, + [1, User(age=7, name="Bond")], + [1, User(age=7, name="Bond")], + 1, + User, + id="INSTANTIATE-list", + ), + param( + None, + {"user": User(age=7, name="Bond")}, + {"user": {"name": "Bond", "age": 7}}, + "user", + dict, + id="default-dict", + ), + param( + None, [1, User(age=7, name="Bond")], + [1, {"name": "Bond", "age": 7}], 1, - id="structured-inside-list", + dict, + id="default-list", ), ], ) -class TestSCMode: - @fixture - def cfg(self, src: Any) -> Any: - return OmegaConf.create(src) - - def test_exclude_structured_configs_default( - self, cfg: Any, ex_dict: Any, ex_dict_config: Any, key: Any - ) -> None: +def test_scmode( + src: Any, + structured_config_mode: Optional[SCMode], + expected: Any, + expected_value_type: Any, + key: Any, +) -> None: + cfg = OmegaConf.create(src) + if structured_config_mode is None: ret = OmegaConf.to_container(cfg) - assert ret == ex_dict - assert isinstance(ret[key], dict) - - def test_scmode_dict( - self, cfg: Any, ex_dict: Any, ex_dict_config: Any, key: Any - ) -> None: - ret = OmegaConf.to_container(cfg, structured_config_mode=SCMode.DICT) - assert ret == ex_dict - assert isinstance(ret[key], dict) - - def test_scmode_dict_config( - self, cfg: Any, ex_dict: Any, ex_dict_config: Any, key: Any - ) -> None: - ret = OmegaConf.to_container(cfg, structured_config_mode=SCMode.DICT_CONFIG) - assert ret == ex_dict_config - assert isinstance(ret[key], DictConfig) + else: + ret = OmegaConf.to_container(cfg, structured_config_mode=structured_config_mode) + assert ret == expected + assert isinstance(ret[key], expected_value_type) @mark.parametrize( @@ -149,6 +197,8 @@ def test_to_container(src: Any, expected: Any, expected_with_resolve: Any) -> No cfg = OmegaConf.create(src) container = OmegaConf.to_container(cfg) assert container == expected + container = OmegaConf.to_container(cfg, structured_config_mode=SCMode.INSTANTIATE) + assert container == expected container = OmegaConf.to_container(cfg, resolve=True) assert container == expected_with_resolve @@ -186,6 +236,185 @@ def test_to_container_missing_inter_no_resolve(src: Any, expected: Any) -> None: assert res == expected +class TestInstantiateStructuredConfigs: + @fixture( + params=[ + "tests.structured_conf.data.dataclasses", + "tests.structured_conf.data.attr_classes", + ] + ) + def module(self, request: Any) -> Any: + return import_module(request.param) + + def round_trip_to_object(self, input_data: Any, **kwargs: Any) -> Any: + serialized = OmegaConf.create(input_data) + round_tripped = OmegaConf.to_object(serialized, **kwargs) + return round_tripped + + def test_basic(self, module: Any) -> None: + user = self.round_trip_to_object(module.User("Bond", 7)) + assert type(user) is module.User + assert user.name == "Bond" + assert user.age == 7 + + def test_basic_with_missing(self, module: Any) -> None: + with raises(MissingMandatoryValue): + self.round_trip_to_object(module.User()) + + def test_nested(self, module: Any) -> None: + data = self.round_trip_to_object({"user": module.User("Bond", 7)}) + user = data["user"] + assert type(user) is module.User + assert user.name == "Bond" + assert user.age == 7 + + def test_nested_with_missing(self, module: Any) -> None: + with raises(MissingMandatoryValue): + self.round_trip_to_object({"user": module.User()}) + + def test_list(self, module: Any) -> None: + lst = self.round_trip_to_object(module.UserList([module.User("Bond", 7)])) + assert type(lst) is module.UserList + assert len(lst.list) == 1 + user = lst.list[0] + assert type(user) is module.User + assert user.name == "Bond" + assert user.age == 7 + + def test_list_with_missing(self, module: Any) -> None: + with raises(MissingMandatoryValue): + self.round_trip_to_object(module.UserList) + + def test_dict(self, module: Any) -> None: + user_dict = self.round_trip_to_object( + module.UserDict({"user007": module.User("Bond", 7)}) + ) + assert type(user_dict) is module.UserDict + assert len(user_dict.dict) == 1 + user = user_dict.dict["user007"] + assert type(user) is module.User + assert user.name == "Bond" + assert user.age == 7 + + def test_dict_with_missing(self, module: Any) -> None: + with raises(MissingMandatoryValue): + self.round_trip_to_object(module.UserDict) + + def test_nested_object(self, module: Any) -> None: + cfg = OmegaConf.structured(module.NestedConfig) + # fill in missing values: + cfg.default_value = module.NestedSubclass(mandatory_missing=123) + cfg.user_provided_default.mandatory_missing = 456 + + nested: Any = OmegaConf.to_object(cfg) + assert type(nested) is module.NestedConfig + assert type(nested.default_value) is module.NestedSubclass + assert type(nested.user_provided_default) is module.Nested + + assert nested.default_value.mandatory_missing == 123 + assert nested.default_value.additional == 20 + assert nested.user_provided_default.mandatory_missing == 456 + + def test_nested_object_with_missing(self, module: Any) -> None: + with raises(MissingMandatoryValue): + self.round_trip_to_object(module.NestedConfig) + + def test_to_object_resolve_is_True_by_default(self, module: Any) -> None: + interp = self.round_trip_to_object(module.Interpolation) + assert type(interp) is module.Interpolation + + assert interp.z1 == 100 + assert interp.z2 == "100_200" + + def test_to_container_INSTANTIATE_resolve_False(self, module: Any) -> None: + """Test the lower level `to_container` API with SCMode.INSTANTIATE and resolve=False""" + src = dict( + obj=module.RelativeInterpolation(), + interp_x="${obj.x}", + interp_x_y="${obj.x}_${obj.x}", + ) + nested = OmegaConf.create(src) + container = OmegaConf.to_container( + nested, resolve=False, structured_config_mode=SCMode.INSTANTIATE + ) + assert isinstance(container, dict) + assert container["interp_x"] == "${obj.x}" + assert container["interp_x_y"] == "${obj.x}_${obj.x}" + assert container["obj"].z1 == 100 + assert container["obj"].z2 == "100_200" + + def test_to_container_INSTANTIATE_enum_to_str_True(self, module: Any) -> None: + """Test the lower level `to_container` API with SCMode.INSTANTIATE and resolve=False""" + src = dict( + color=Color.BLUE, + obj=module.EnumOptional(), + ) + nested = OmegaConf.create(src) + container = OmegaConf.to_container( + nested, enum_to_str=True, structured_config_mode=SCMode.INSTANTIATE + ) + assert isinstance(container, dict) + assert container["color"] == "BLUE" + assert container["obj"].not_optional is Color.BLUE + + def test_to_object_InterpolationResolutionError(self, module: Any) -> None: + with raises(InterpolationResolutionError): + cfg = OmegaConf.structured(module.NestedWithAny) + cfg.var.mandatory_missing = 123 + OmegaConf.to_object(cfg) + + def test_nested_object_with_Any_ref_type(self, module: Any) -> None: + cfg = OmegaConf.structured(module.NestedWithAny()) + cfg.var.mandatory_missing = 123 + with open_dict(cfg): + cfg.value_at_root = 456 + nested = self.round_trip_to_object(cfg) + assert type(nested) is module.NestedWithAny + + assert type(nested.var) is module.Nested + assert nested.var.with_default == 10 + assert nested.var.mandatory_missing == 123 + assert nested.var.interpolation == 456 + + def test_str2user_instantiate(self, module: Any) -> None: + cfg = OmegaConf.structured(module.DictSubclass.Str2User()) + cfg.bond = module.User(name="James Bond", age=7) + data = self.round_trip_to_object(cfg) + + assert type(data) is module.DictSubclass.Str2User + assert type(data.bond) is module.User + assert data.bond == module.User("James Bond", 7) + + def test_str2user_with_field_instantiate(self, module: Any) -> None: + cfg = OmegaConf.structured(module.DictSubclass.Str2UserWithField()) + cfg.mp = module.User(name="Moneypenny", age=11) + data = self.round_trip_to_object(cfg) + + assert type(data) is module.DictSubclass.Str2UserWithField + assert type(data.foo) is module.User + assert data.foo == module.User("Bond", 7) + assert type(data.mp) is module.User + assert data.mp == module.User("Moneypenny", 11) + + def test_str2str_with_field_instantiate(self, module: Any) -> None: + cfg = OmegaConf.structured(module.DictSubclass.Str2StrWithField()) + cfg.hello = "world" + data = self.round_trip_to_object(cfg) + + assert type(data) is module.DictSubclass.Str2StrWithField + assert data.foo == "bar" + assert data.hello == "world" + + def test_setattr_for_user_with_extra_field(self, module: Any) -> None: + cfg = OmegaConf.structured(module.User(name="James Bond", age=7)) + with open_dict(cfg): + cfg.extra_field = 123 + + user: Any = OmegaConf.to_object(cfg) + assert type(user) is module.User + assert user.extra_field == 123 + + class TestEnumToStr: """Test the `enum_to_str` argument to the `OmegaConf.to_container function`""" diff --git a/tests/test_utils.py b/tests/test_utils.py index 59d51488d..9811a5e97 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -25,15 +25,7 @@ StringNode, ) from omegaconf.omegaconf import _node_wrap -from tests import ( - Color, - ConcretePlugin, - Dataframe, - IllegalType, - Plugin, - User, - does_not_raise, -) +from tests import Color, ConcretePlugin, Dataframe, IllegalType, Plugin, User @mark.parametrize( @@ -180,18 +172,12 @@ def test_valid_value_annotation_type(type_: type, expected: bool) -> None: assert valid_value_annotation_type(type_) == expected -@mark.parametrize( - "test_cls_or_obj, expectation", - [ - (_TestDataclass, does_not_raise()), - (_TestDataclass(), does_not_raise()), - (_TestAttrsClass, does_not_raise()), - (_TestAttrsClass(), does_not_raise()), - ("invalid", raises(ValueError)), - ], -) -def test_get_structured_config_data(test_cls_or_obj: Any, expectation: Any) -> None: - with expectation: +class TestGetStructuredConfigInfo: + @mark.parametrize( + "test_cls_or_obj", + [_TestDataclass, _TestDataclass(), _TestAttrsClass, _TestAttrsClass()], + ) + def test_get_structured_config_data(self, test_cls_or_obj: Any) -> None: d = _utils.get_structured_config_data(test_cls_or_obj) assert d["x"] == 10 assert d["s"] == "foo" @@ -201,6 +187,22 @@ def test_get_structured_config_data(test_cls_or_obj: Any, expectation: Any) -> N assert d["list1"] == [] assert d["dict1"] == {} + def test_get_structured_config_data_throws_ValueError(self) -> None: + with raises(ValueError): + _utils.get_structured_config_data("invalid") + + @mark.parametrize( + "test_cls_or_obj", + [_TestDataclass, _TestDataclass(), _TestAttrsClass, _TestAttrsClass()], + ) + def test_get_structured_config_field_names(self, test_cls_or_obj: Any) -> None: + field_names = _utils.get_structured_config_field_names(test_cls_or_obj) + assert field_names == ["x", "s", "b", "f", "e", "list1", "dict1"] + + def test_get_structured_config_field_names_throws_ValueError(self) -> None: + with raises(ValueError): + _utils.get_structured_config_field_names("invalid") + @mark.parametrize( "test_cls",