Repository: facebookresearch/hydra

Issue:
[Bug] Assigning a dictionary to untyped field fails
I am expecting this to work:
```
$ python examples/tutorials/basic/your_first_hydra_app/5_defaults/my_app.py  'db.driver={a:10,b:20}' 
Could not override 'db.driver'. No match in config.
To append to your config use +db.driver={a:10,b:20}
Key 'a' is not in struct
        full_key: db.driver.a
        reference_type=Any
        object_type=dict
```


Discussion:

Patch:
diff --git a/hydra/_internal/config_loader_impl.py b/hydra/_internal/config_loader_impl.py
--- a/hydra/_internal/config_loader_impl.py
+++ b/hydra/_internal/config_loader_impl.py
@@ -8,6 +8,7 @@
 import warnings
 from collections import defaultdict
 from dataclasses import dataclass
+from textwrap import dedent
 from typing import Any, Dict, List, Optional, Tuple
 
 from omegaconf import Container, DictConfig, ListConfig, OmegaConf, open_dict
@@ -70,7 +71,7 @@ class ConfigLoaderImpl(ConfigLoader):
     def __init__(
         self,
         config_search_path: ConfigSearchPath,
-        default_strict: Optional[bool] = None,
+        default_strict: Optional[bool] = True,
     ) -> None:
         self.default_strict = default_strict
         self.all_config_checked: List[LoadTrace] = []
@@ -85,7 +86,9 @@ def split_by_override_type(
         config_group_overrides = []
         config_overrides = []
         for override in overrides:
-            if not self.repository.group_exists(override.key_or_group):
+            is_group = self.repository.group_exists(override.key_or_group)
+            is_dict = isinstance(override.value(), dict)
+            if is_dict or not is_group:
                 config_overrides.append(override)
             else:
                 config_group_overrides.append(override)
@@ -193,10 +196,13 @@ def _load_configuration(
                         else:
                             example_override = f"key='{vals}'"
 
-                        msg = f"""Ambiguous value for argument '{x.input_line}'
-1. To use it as a list, use key=[value1,value2]
-2. To use it as string, quote the value: {example_override}
-3. To sweep over it, add --multirun to your command line"""
+                        msg = dedent(
+                            f"""\
+                            Ambiguous value for argument '{x.input_line}'
+                            1. To use it as a list, use key=[value1,value2]
+                            2. To use it as string, quote the value: {example_override}
+                            3. To sweep over it, add --multirun to your command line"""
+                        )
                         raise ConfigCompositionException(msg)
                     else:
                         raise ConfigCompositionException(
@@ -493,7 +499,9 @@ def _apply_overrides_to_config(overrides: List[Override], cfg: DictConfig) -> No
                             del node[key[last_dot + 1 :]]
 
                 elif override.is_add():
-                    if OmegaConf.select(cfg, key, throw_on_missing=False) is None:
+                    if OmegaConf.select(
+                        cfg, key, throw_on_missing=False
+                    ) is None or isinstance(value, (dict, list)):
                         with open_dict(cfg):
                             OmegaConf.update(cfg, key, value)
                     else:
@@ -505,7 +513,7 @@ def _apply_overrides_to_config(overrides: List[Override], cfg: DictConfig) -> No
                         OmegaConf.update(cfg, key, value)
                     except (ConfigAttributeError, ConfigKeyError) as ex:
                         raise ConfigCompositionException(
-                            f"Could not override '{override.key_or_group}'. No match in config."
+                            f"Could not override '{override.key_or_group}'."
                             f"\nTo append to your config use +{override.input_line}"
                         ) from ex
             except OmegaConfBaseException as ex:


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -2,6 +2,8 @@
 from dataclasses import dataclass
 from typing import Any, List, Tuple
 
+from omegaconf import MISSING
+
 from hydra.types import TargetConf
 
 
@@ -102,3 +104,15 @@ class AdamConf(TargetConf):
 class BadAdamConf(TargetConf):
     # Missing str annotation
     _target_ = "tests.Adam"
+
+
+@dataclass
+class User:
+    name: str = MISSING
+    age: int = MISSING
+
+
+@dataclass
+class UserGroup:
+    name: str = MISSING
+    users: List[User] = MISSING
diff --git a/tests/test_apps/app_with_cfg_groups/conf/config.yaml b/tests/test_apps/app_with_cfg_groups/conf/config.yaml
--- a/tests/test_apps/app_with_cfg_groups/conf/config.yaml
+++ b/tests/test_apps/app_with_cfg_groups/conf/config.yaml
@@ -1,3 +1,5 @@
 # @package _global_
 defaults:
   - optimizer: nesterov
+
+optimizer: ???
diff --git a/tests/test_apps/app_with_cfg_groups/conf/missing_default.yaml b/tests/test_apps/app_with_cfg_groups/conf/missing_default.yaml
new file mode 100644
--- /dev/null
+++ b/tests/test_apps/app_with_cfg_groups/conf/missing_default.yaml
@@ -0,0 +1,3 @@
+# @package _global_
+defaults:
+  - optimizer: ???
diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py
--- a/tests/test_config_loader.py
+++ b/tests/test_config_loader.py
@@ -13,9 +13,14 @@
 from hydra.core.config_store import ConfigStore, ConfigStoreWithProvider
 from hydra.core.override_parser.overrides_parser import OverridesParser
 from hydra.core.utils import env_override, setup_globals
-from hydra.errors import HydraException, MissingConfigException
+from hydra.errors import (
+    ConfigCompositionException,
+    HydraException,
+    MissingConfigException,
+)
 from hydra.test_utils.test_utils import chdir_hydra_root
 from hydra.types import RunMode
+from tests import UserGroup
 
 chdir_hydra_root()
 
@@ -438,19 +443,16 @@ def test_load_config_file_with_schema_validation(
         expected.append(LoadTrace("db/mysql", path, "main", "this_test"))
         assert config_loader.get_load_history() == expected
 
-    def test_assign_null(
-        self, hydra_restore_singletons: Any, path: str, recwarn: Any
-    ) -> None:
+    def test_assign_null(self, hydra_restore_singletons: Any, path: str) -> None:
         config_loader = ConfigLoaderImpl(
             config_search_path=create_config_search_path(path)
         )
         cfg = config_loader.load_configuration(
-            config_name="config.yaml", overrides=["abc=null"], run_mode=RunMode.RUN
+            config_name="config.yaml", overrides=["+abc=null"], run_mode=RunMode.RUN
         )
         with open_dict(cfg):
             del cfg["hydra"]
         assert cfg == {"normal_yaml_config": True, "abc": None}
-        assert len(recwarn) == 0
 
     def test_sweep_config_cache(
         self, hydra_restore_singletons: Any, path: str, monkeypatch: Any
@@ -463,7 +465,7 @@ def test_sweep_config_cache(
         master_cfg = config_loader.load_configuration(
             config_name="config.yaml",
             strict=False,
-            overrides=["time='${now:%H-%M-%S}'", "home='${env:HOME}'"],
+            overrides=["+time=${now:%H-%M-%S}", "+home=${env:HOME}"],
             run_mode=RunMode.RUN,
         )
 
@@ -477,7 +479,7 @@ def test_sweep_config_cache(
 
         sweep_cfg = config_loader.load_sweep_config(
             master_config=master_cfg,
-            sweep_overrides=["time='${now:%H-%M-%S}'", "home='${env:HOME}'"],
+            sweep_overrides=["+time=${now:%H-%M-%S}", "+home=${env:HOME}"],
         )
 
         sweep_cfg_cache = OmegaConf.get_cache(sweep_cfg)
@@ -1096,8 +1098,7 @@ def test_delete_by_assigning_null_is_deprecated() -> None:
             pytest.raises(
                 HydraException,
                 match=re.escape(
-                    "Could not override 'x'. No match in config."
-                    "\nTo append to your config use +x=10"
+                    "Could not override 'x'.\nTo append to your config use +x=10"
                 ),
             ),
             id="append:error:no_match",
@@ -1126,6 +1127,46 @@ def test_delete_by_assigning_null_is_deprecated() -> None:
         pytest.param({"x": 20}, ["x=10"], {"x": 10}, id="override"),
         pytest.param({"x": None}, ["x=[1,2,3]"], {"x": [1, 2, 3]}, id="override:list"),
         pytest.param({"x": 20}, ["x=null"], {"x": None}, id="override_with_null"),
+        pytest.param({"x": {"a": 10}}, ["x={a:20}"], {"x": {"a": 20}}, id="merge_dict"),
+        pytest.param(
+            {"x": {"a": 10, "b": None}},
+            ["x={b:20}"],
+            {"x": {"a": 10, "b": 20}},
+            id="merge_dict",
+        ),
+        pytest.param(
+            {"x": {"a": 10}},
+            ["+x={b:20}"],
+            {"x": {"a": 10, "b": 20}},
+            id="merge_dict",
+        ),
+        pytest.param(
+            UserGroup,
+            ["name=agents", "users=[]"],
+            {"name": "agents", "users": []},
+            id="merge_list",
+        ),
+        pytest.param(
+            UserGroup,
+            ["name=agents", "users=[{}]"],
+            {"name": "agents", "users": [{"name": MISSING, "age": MISSING}]},
+            id="merge_list",
+        ),
+        pytest.param(
+            UserGroup,
+            ["name=agents", "users=[{name:bond,age:7}]"],
+            {"name": "agents", "users": [{"name": "bond", "age": 7}]},
+            id="merge_list",
+        ),
+        pytest.param(
+            UserGroup,
+            ["name=agents", "users=[{name:bond,age:nope}]"],
+            pytest.raises(
+                ConfigCompositionException,
+                match=re.escape("Error merging override users=[{name:bond,age:nope}]"),
+            ),
+            id="merge_list",
+        ),
         # delete
         pytest.param({"x": 20}, ["~x"], {}, id="delete"),
         pytest.param({"x": 20}, ["~x=20"], {}, id="delete_strict"),
@@ -1186,3 +1227,56 @@ def test_apply_overrides_to_config(
     else:
         with expected:
             ConfigLoaderImpl._apply_overrides_to_config(overrides=parsed, cfg=cfg)
+
+
+@pytest.mark.parametrize(  # type: ignore
+    "config,overrides,expected",
+    [
+        pytest.param(
+            "config",
+            [],
+            {"optimizer": {"type": "nesterov", "lr": 0.001}},
+            id="default_choice",
+        ),
+        pytest.param(
+            "config",
+            ["optimizer=adam"],
+            {"optimizer": {"type": "adam", "lr": 0.1, "beta": 0.01}},
+            id="default_change",
+        ),
+        pytest.param(
+            # need to unset optimizer config group first, otherwise they get merged
+            "config",
+            ["optimizer={type:nesterov2,lr:1}"],
+            {"optimizer": {"type": "nesterov2", "lr": 1}},
+            id="dict_merge",
+        ),
+        pytest.param(
+            # need to unset optimizer config group first, otherwise they get merged
+            "config",
+            ["+optimizer={foo:10}"],
+            {"optimizer": {"type": "nesterov", "lr": 0.001, "foo": 10}},
+            id="dict_merge_append",
+        ),
+        pytest.param(
+            # need to unset optimizer config group first, otherwise they get merged
+            "missing_default",
+            ["~optimizer", "+optimizer={type:super,good:true,fast:true}"],
+            {"optimizer": {"type": "super", "good": True, "fast": True}},
+            id="dict_replace_default",
+        ),
+    ],
+)
+def test_overriding_with_dict(config: str, overrides: Any, expected: Any) -> None:
+    config_loader = ConfigLoaderImpl(
+        config_search_path=create_config_search_path(
+            "tests/test_apps/app_with_cfg_groups/conf"
+        ),
+    )
+
+    cfg = config_loader.load_configuration(
+        config_name=config, overrides=overrides, run_mode=RunMode.RUN
+    )
+    with open_dict(cfg):
+        del cfg["hydra"]
+    assert cfg == expected