Repository: facebookresearch/hydra

Issue:
[Feature Request] instantiate(..., _convert_="object") to integrate with OmegaConf.to_object
# 🚀 Feature Request

It would be nice to combine the benefits of `hydra.utils.instantiate` with `OmegaConf.to_object`.

## Motivation

Currently users of Hydra have several ways to recursively convert OmegaConf objects (`DictConfig` and `ListConfig`) into pure python objects:
  - `OmegaConf.to_container` converts OmegaConf objects into nested dicts and lists.
  - `hydra.utils.instantiate` looks for `_target_` fields nested inside an OmegaConf object; `_target_` is used to name a specific class to instantiate (or a specific function to call) using the config's fields as parameters.
  - `OmegaConf.to_object` recursively converts OmegaConf objects to dicts and lists, giving special treatment to structured configs (which are instantiated as actual instances of the underlying dataclass or attrs class).

The motivation for this feature request has to do with combining structured configs and `_target_` keywords in the same config object. Consider the following example:
```python
# schemas.py
from dataclasses import dataclass
from typing import Any

@dataclass
class Structured:
    x: int

class UnStructured:
    y: int

    def __init__(self, y: int):
        self.y = y

@dataclass
class MainConf:
    s: Structured
    u: Any
```
```python
# main.py
from omegaconf import DictConfig, OmegaConf
from hydra.utils import instantiate
from schemas import MainConf, Structured, UnStructured

cfg = OmegaConf.create(
    MainConf(s=Structured(x=123), u={"_target_": "schemas.UnStructured", "y": 456})
)

# Neither `instantiate` nor `OmegaConf.to_object` gets the job done:

obj = OmegaConf.to_object(cfg)
assert isinstance(obj, MainConf)
assert isinstance(obj.s, Structured)
assert isinstance(obj.u, dict)  # field `u` is not converted to `UnStructured`

inst = instantiate(cfg)
assert isinstance(inst, DictConfig)  # top-level config is not converted to `MainConf`
assert isinstance(inst.s, DictConfig)  # field `s` is not converted to `Structured`
assert isinstance(inst.u, UnStructured)
```
### A Workaround
Combining `OmegaConf.to_object` with `hydra.utils.instantiate` often works as expected:
```python
obj_inst = OmegaConf.to_object(instantiate(cfg))
assert isinstance(obj_inst, MainConf)
assert isinstance(obj_inst.s, Structured)
assert isinstance(obj_inst.u, UnStructured)
```
### Edge Cases
There are some edge cases that are not handled so well by `to_object(instantiate(...))`:

For example things do not work as expected if the `_target_`-config takes a structured config as an argument:
```python
class UnStructured2:
    y: int
    z: Structured
    def __init__(self, y: int, z: Structured):
        self.y = y
        self.z = z
```
```python
from schemas import UnStructured2
cfg2 = OmegaConf.create(
    MainConf(s=Structured(x=123), u={"_target_": "schemas.UnStructured2", "y": 456, "z": Structured(x=789)})
)
# cfg2 = OmegaConf.create({"_target_": "schemas.UnStructured2", "y": 456, "z": Structured(x=123)})
obj_inst2 = OmegaConf.to_object(instantiate(cfg2))
assert isinstance(obj_inst2, MainConf)
assert isinstance(obj_inst2.u, UnStructured2)
assert isinstance(obj_inst2.u.y, int)
assert isinstance(obj_inst2.u.z, Structured)  # Assertion Error: actual type is DictConfig
```
Another edge case: calling `to_object(instantiate(...))` does not work if there is a `_target_` keyword at the top-level of the config object:
```python
from pytest import raises
cfg3 = OmegaConf.create({"_target_": "schemas.UnStructured", "y": 456})
with raises(ValueError):
    OmegaConf.to_object(instantiate(cfg3))
    # "ValueError: Input cfg is not an OmegaConf config object (UnStructured)"
```
## Pitch

I propose expanding the `_convert_` parameter to `instantiate` with a new `"object"` value, so that calling
```python
instantiate(cfg, _convert_="object")
```
is almost equivalent to
```python
OmegaConf.to_object(instantiate(cfg))
```
with special handling of the above edge-cases.

Since calling `OmegaConf.to_object(instantiate(cfg))` handles almost all use-cases, I expect that this feature request will not be of high priority.



Discussion:
Thanks Jasha.
Using to_object as a conversion strategy is something worth consider for the next version of Hydra.

As you are saying, it does not seem high pri at the moment but we can look at it more closely for the next version.
One design question to ask in this context:
When `_convert_="object"`, what if a structured config has a field named `_target_`?
My instinct is that the config should be instantiated (i.e. `_target_` should get called) rather than the config being converted to an instance of the underlying dataclass/attrs class.

Resolving patch:
diff --git a/hydra/_internal/instantiate/_instantiate2.py b/hydra/_internal/instantiate/_instantiate2.py
--- a/hydra/_internal/instantiate/_instantiate2.py
+++ b/hydra/_internal/instantiate/_instantiate2.py
@@ -160,8 +160,12 @@ def instantiate(config: Any, *args: Any, **kwargs: Any) -> Any:
                         none    : Passed objects are DictConfig and ListConfig, default
                         partial : Passed objects are converted to dict and list, with
                                   the exception of Structured Configs (and their fields).
+                        object  : Passed objects are converted to dict and list.
+                                  Structured Configs are converted to instances of the
+                                  backing dataclass / attr class.
                         all     : Passed objects are dicts, lists and primitives without
-                                  a trace of OmegaConf containers
+                                  a trace of OmegaConf containers. Structured configs
+                                  are converted to dicts / lists too.
                    _partial_: If True, return functools.partial wrapped method or object
                               False by default. Configure per target.
     :param args: Optional positional parameters pass-through
@@ -264,6 +268,10 @@ def _convert_node(node: Any, convert: Union[ConvertMode, str]) -> Any:
             node = OmegaConf.to_container(
                 node, resolve=True, structured_config_mode=SCMode.DICT_CONFIG
             )
+        elif convert == ConvertMode.OBJECT:
+            node = OmegaConf.to_container(
+                node, resolve=True, structured_config_mode=SCMode.INSTANTIATE
+            )
     return node
 
 
@@ -310,8 +318,8 @@ def instantiate_node(
             for item in node._iter_ex(resolve=True)
         ]
 
-        if convert in (ConvertMode.ALL, ConvertMode.PARTIAL):
-            # If ALL or PARTIAL, use plain list as container
+        if convert in (ConvertMode.ALL, ConvertMode.PARTIAL, ConvertMode.OBJECT):
+            # If ALL or PARTIAL or OBJECT, use plain list as container
             return items
         else:
             # Otherwise, use ListConfig as container
@@ -338,9 +346,10 @@ def instantiate_node(
 
             return _call_target(_target_, partial, args, kwargs, full_key)
         else:
-            # If ALL or PARTIAL non structured, instantiate in dict and resolve interpolations eagerly.
+            # If ALL or PARTIAL non structured or OBJECT non structured,
+            # instantiate in dict and resolve interpolations eagerly.
             if convert == ConvertMode.ALL or (
-                convert == ConvertMode.PARTIAL
+                convert in (ConvertMode.PARTIAL, ConvertMode.OBJECT)
                 and node._metadata.object_type in (None, dict)
             ):
                 dict_items = {}
@@ -359,6 +368,8 @@ def instantiate_node(
                     )
                 cfg._set_parent(node)
                 cfg._metadata.object_type = node._metadata.object_type
+                if convert == ConvertMode.OBJECT:
+                    return OmegaConf.to_object(cfg)
                 return cfg
 
     else:
diff --git a/hydra/types.py b/hydra/types.py
--- a/hydra/types.py
+++ b/hydra/types.py
@@ -60,7 +60,12 @@ class ConvertMode(Enum):
 
     If "partial", config-like configs will be converted to native python
     containers (list and dict), unless they are structured configs (
-    dataclasses or attr instances).
+    dataclasses or attr instances). Structured configs remain as DictConfig objects.
+
+    If "object", config-like configs will be converted to native python
+    containers (list and dict), unless they are structured configs (
+    dataclasses or attr instances). Structured configs are converted to instances
+    of the backing dataclass or attr class using OmegaConf.to_object.
 
     If "all", config-like configs will all be converted to native python
     containers (list and dict).
@@ -70,6 +75,9 @@ class ConvertMode(Enum):
     NONE = "none"
     # Convert the OmegaConf config to primitive container, Structured Configs are preserved
     PARTIAL = "partial"
+    # Convert the OmegaConf config to primitive container, Structured Configs are converted to
+    # dataclass / attr class instances.
+    OBJECT = "object"
     # Fully convert the OmegaConf config to primitive containers (dict, list and primitives).
     ALL = "all"
 


Test patch:
diff --git a/tests/instantiate/__init__.py b/tests/instantiate/__init__.py
--- a/tests/instantiate/__init__.py
+++ b/tests/instantiate/__init__.py
@@ -385,6 +385,10 @@ def __eq__(self, other: Any) -> Any:
             return self.a == other.a and self.b == other.b
         return False
 
+    @property
+    def _fields(self) -> List[str]:
+        return ["a", "b"]
+
 
 @dataclass
 class SimpleClassPrimitiveConf:
@@ -422,6 +426,13 @@ def recisinstance(got: Any, expected: Any) -> bool:
         return False
     if isinstance(expected, collections.abc.Mapping):
         return all(recisinstance(got[key], expected[key]) for key in expected)
-    elif isinstance(expected, collections.abc.Iterable):
+    elif isinstance(expected, collections.abc.Iterable) and not isinstance(
+        expected, str
+    ):
         return all(recisinstance(got[idx], exp) for idx, exp in enumerate(expected))
+    elif hasattr(expected, "_fields"):
+        return all(
+            recisinstance(getattr(got, key), getattr(expected, key))
+            for key in expected._fields  # type: ignore
+        )
     return True
diff --git a/tests/instantiate/test_instantiate.py b/tests/instantiate/test_instantiate.py
--- a/tests/instantiate/test_instantiate.py
+++ b/tests/instantiate/test_instantiate.py
@@ -1505,6 +1505,7 @@ def test_cannot_locate_target(instantiate_func: Any) -> None:
         param(None, False, id="unspecified"),
         param(ConvertMode.NONE, False, id="none"),
         param(ConvertMode.PARTIAL, True, id="partial"),
+        param(ConvertMode.OBJECT, True, id="object"),
         param(ConvertMode.ALL, True, id="all"),
     ],
 )
@@ -1587,6 +1588,8 @@ def test_convert_params_override(
         param("none", id="none"),
         param(ConvertMode.PARTIAL, id="partial"),
         param("partial", id="partial"),
+        param(ConvertMode.OBJECT, id="object"),
+        param("object", id="object"),
         param(ConvertMode.ALL, id="all"),
         param("all", id="all"),
     ],
@@ -1619,7 +1622,7 @@ def test_convert_params(
     kwargs = {"a": {"_convert_": convert_mode}}
     ret = instantiate_func(cfg.obj, **kwargs)
 
-    if convert_mode in (ConvertMode.PARTIAL, ConvertMode.ALL):
+    if convert_mode in (ConvertMode.PARTIAL, ConvertMode.OBJECT, ConvertMode.ALL):
         assert isinstance(ret.a.a, dict)
         assert isinstance(ret.a.b, list)
     elif convert_mode in (None, ConvertMode.NONE):
@@ -1677,6 +1680,7 @@ def test_convert_and_recursive_node(
                 ),
                 SimpleDataClass(a=SimpleDataClass(a={"foo": 99}, b=[1, 99]), b=None),
                 SimpleDataClass(a=SimpleDataClass(a={"foo": 99}, b=[1, 99]), b=None),
+                SimpleDataClass(a=SimpleDataClass(a={"foo": 99}, b=[1, 99]), b=None),
             ),
             id="dataclass+dataclass",
         ),
@@ -1702,6 +1706,7 @@ def test_convert_and_recursive_node(
                 ),
                 SimpleClass(a=SimpleDataClass(a={"foo": 99}, b=[1, 99]), b=None),
                 SimpleClass(a=SimpleDataClass(a={"foo": 99}, b=[1, 99]), b=None),
+                SimpleClass(a=SimpleDataClass(a={"foo": 99}, b=[1, 99]), b=None),
             ),
             id="class+dataclass",
         ),
@@ -1729,16 +1734,68 @@ def test_convert_and_recursive_node(
                 ),
                 {"a": SimpleDataClass(a={"foo": 99}, b=[1, 99]), "b": None},
                 {"a": SimpleDataClass(a={"foo": 99}, b=[1, 99]), "b": None},
+                {"a": SimpleDataClass(a={"foo": 99}, b=[1, 99]), "b": None},
             ),
             id="dict+dataclass",
         ),
+        param(
+            {
+                "obj": {
+                    "_target_": "tests.instantiate.SimpleClass",
+                    "a": SimpleDataClass(a="foo"),
+                    "b": None,
+                }
+            },
+            (
+                SimpleClass(a=OmegaConf.create({"a": "foo", "b": None}), b=None),
+                SimpleClass(a=OmegaConf.create({"a": "foo", "b": None}), b=None),
+                SimpleClass(a=SimpleDataClass(a="foo", b=None), b=None),
+                SimpleClass(a={"a": "foo", "b": None}, b=None),
+            ),
+            id="class+dataclass_instance",
+        ),
+        param(
+            {
+                "obj": SimpleDataClass(
+                    a={
+                        "_target_": "tests.instantiate.SimpleClass",
+                        "a": "foo",
+                        "b": None,
+                    }
+                )
+            },
+            (
+                OmegaConf.create(
+                    {"a": SimpleClass(a="foo", b=None), "b": None},
+                    flags={"allow_objects": True},
+                ),
+                OmegaConf.create(
+                    {"a": SimpleClass(a="foo", b=None), "b": None},
+                    flags={"allow_objects": True},
+                ),
+                SimpleDataClass(a=SimpleClass(a="foo", b=None), b=None),
+                {"a": SimpleClass(a="foo", b=None), "b": None},
+            ),
+            id="dataclass_instance+class",
+        ),
+        param(
+            {"obj": SimpleClassDefaultPrimitiveConf(a=SimpleDataClass(a="foo"))},
+            (
+                SimpleClass(a=OmegaConf.create({"a": "foo", "b": None}), b=None),
+                SimpleClass(a=OmegaConf.create({"a": "foo", "b": None}), b=None),
+                SimpleClass(a=SimpleDataClass(a="foo", b=None), b=None),
+                SimpleClass(a={"a": "foo", "b": None}, b=None),
+            ),
+            id="dataclass_instance_with_target+dataclass_instance",
+        ),
     ],
 )
 def test_instantiate_convert_dataclasses(
-    instantiate_func: Any, config: Any, expected: Tuple[Any, Any, Any]
+    instantiate_func: Any, config: Any, expected: Tuple[Any, Any, Any, Any]
 ) -> None:
     """Instantiate on nested dataclass + dataclass."""
-    modes = [ConvertMode.NONE, ConvertMode.PARTIAL, ConvertMode.ALL]
+    modes = [ConvertMode.NONE, ConvertMode.PARTIAL, ConvertMode.OBJECT, ConvertMode.ALL]
+    assert len(modes) == len(expected)
     for exp, mode in zip(expected, modes):
         # create DictConfig to ensure interpolations are working correctly when we pass a cfg.obj
         cfg = OmegaConf.create(config)
@@ -1753,6 +1810,7 @@ def test_instantiate_convert_dataclasses(
         param(ConvertMode.NONE, DictConfig, ListConfig, id="none"),
         param(ConvertMode.ALL, dict, list, id="all"),
         param(ConvertMode.PARTIAL, dict, list, id="partial"),
+        param(ConvertMode.OBJECT, dict, list, id="object"),
     ],
 )
 def test_instantiated_regular_class_container_types(
@@ -1789,6 +1847,19 @@ def test_instantiated_regular_class_container_types_partial(
     assert OmegaConf.get_type(ret.b) is User
 
 
+def test_instantiated_regular_class_container_types_object(
+    instantiate_func: Any,
+) -> None:
+    cfg = {
+        "_target_": "tests.instantiate.SimpleClass",
+        "a": {},
+        "b": User(name="Bond", age=7),
+    }
+    ret = instantiate_func(cfg, _convert_=ConvertMode.OBJECT)
+    assert isinstance(ret.a, dict)
+    assert isinstance(ret.b, User)
+
+
 def test_instantiated_regular_class_container_types_partial2(
     instantiate_func: Any,
 ) -> None:
@@ -1804,6 +1875,20 @@ def test_instantiated_regular_class_container_types_partial2(
     assert OmegaConf.get_type(ret.a[1]) is User
 
 
+def test_instantiated_regular_class_container_types_object2(
+    instantiate_func: Any,
+) -> None:
+    cfg = {
+        "_target_": "tests.instantiate.SimpleClass",
+        "a": [{}, User(name="Bond", age=7)],
+        "b": None,
+    }
+    ret = instantiate_func(cfg, _convert_=ConvertMode.OBJECT)
+    assert isinstance(ret.a, list)
+    assert isinstance(ret.a[0], dict)
+    assert isinstance(ret.a[1], User)
+
+
 @mark.parametrize(
     "src",
     [
@@ -1828,6 +1913,29 @@ def test_instantiated_regular_class_container_types_partial__recursive(
     assert OmegaConf.get_type(ret.a.b) is User
 
 
+@mark.parametrize(
+    "src",
+    [
+        {
+            "_target_": "tests.instantiate.SimpleClass",
+            "a": {
+                "_target_": "tests.instantiate.SimpleClass",
+                "a": {},
+                "b": User(name="Bond", age=7),
+            },
+            "b": None,
+        }
+    ],
+)
+def test_instantiated_regular_class_container_types_object__recursive(
+    instantiate_func: Any, config: Any
+) -> None:
+    ret = instantiate_func(config, _convert_=ConvertMode.OBJECT)
+    assert isinstance(ret.a, SimpleClass)
+    assert isinstance(ret.a.a, dict)
+    assert isinstance(ret.a.b, User)
+
+
 @mark.parametrize(
     "input_,is_primitive,expected",
     [
@@ -1887,6 +1995,7 @@ def test_convert_in_config(
         (ConvertMode.ALL, ConvertMode.ALL, True),
         (ConvertMode.NONE, "none", True),
         (ConvertMode.PARTIAL, "Partial", True),
+        (ConvertMode.OBJECT, "object", True),
         (ConvertMode.ALL, ConvertMode.NONE, False),
         (ConvertMode.NONE, "all", False),
     ],