Repository: facebookresearch/hydra

Issue:
Handle Frozen dataclasses during config composition
marking a dataclass as frozen should prevent all manipulation, including composing and overriding from the command line.


Discussion:

Patch:
diff --git a/examples/patterns/write_protect_config_node/frozen.py b/examples/patterns/write_protect_config_node/frozen.py
new file mode 100644
--- /dev/null
+++ b/examples/patterns/write_protect_config_node/frozen.py
@@ -0,0 +1,25 @@
+# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved
+from dataclasses import dataclass
+
+import hydra
+from hydra.core.config_store import ConfigStore
+
+
+@dataclass(frozen=True)
+class SerialPort:
+    baud_rate: int = 19200
+    data_bits: int = 8
+    stop_bits: int = 1
+
+
+cs = ConfigStore.instance()
+cs.store(name="config", node=SerialPort)
+
+
+@hydra.main(config_name="config")
+def my_app(cfg: SerialPort) -> None:
+    print(cfg)
+
+
+if __name__ == "__main__":
+    my_app()
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
@@ -10,7 +10,7 @@
 from dataclasses import dataclass
 from typing import Any, Dict, List, Optional, Tuple
 
-from omegaconf import DictConfig, ListConfig, OmegaConf, open_dict
+from omegaconf import Container, DictConfig, ListConfig, OmegaConf, open_dict
 from omegaconf.errors import (
     ConfigAttributeError,
     ConfigKeyError,
@@ -201,9 +201,14 @@ def load_configuration(
         job_cfg_type = OmegaConf.get_type(job_cfg)
         if job_cfg_type is not None and not issubclass(job_cfg_type, dict):
             hydra_cfg._promote(job_cfg_type)
+
+            # during the regular merge later the config will retain the readonly flag.
+            _recursive_unset_readonly(hydra_cfg)
             # this is breaking encapsulation a bit. can potentially be implemented in OmegaConf
             hydra_cfg._metadata.ref_type = job_cfg._metadata.ref_type
 
+            OmegaConf.set_readonly(hydra_cfg.hydra, False)
+
         # if defaults are re-introduced by the promotion, remove it.
         if "defaults" in hydra_cfg:
             with open_dict(hydra_cfg):
@@ -834,3 +839,14 @@ def get_overrides_dirname(
     lines.sort()
     ret = re.sub(pattern="[=]", repl=kv_sep, string=item_sep.join(lines))
     return ret
+
+
+def _recursive_unset_readonly(cfg: Container) -> None:
+    if isinstance(cfg, DictConfig):
+        OmegaConf.set_readonly(cfg, None)
+        for k, v in cfg.items_ex(resolve=False):
+            _recursive_unset_readonly(v)
+    elif isinstance(cfg, ListConfig):
+        OmegaConf.set_readonly(cfg, None)
+        for item in cfg:
+            _recursive_unset_readonly(item)
diff --git a/hydra/core/utils.py b/hydra/core/utils.py
--- a/hydra/core/utils.py
+++ b/hydra/core/utils.py
@@ -12,7 +12,7 @@
 from time import localtime, strftime
 from typing import Any, Dict, Optional, Sequence, Tuple, Union, cast
 
-from omegaconf import DictConfig, OmegaConf, open_dict
+from omegaconf import DictConfig, OmegaConf, open_dict, read_write
 
 from hydra.core.hydra_config import HydraConfig
 from hydra.core.singleton import Singleton
@@ -96,8 +96,9 @@ def run_job(
         ret = JobReturn()
         ret.working_dir = working_dir
         task_cfg = copy.deepcopy(config)
-        with open_dict(task_cfg):
-            del task_cfg["hydra"]
+        with read_write(task_cfg):
+            with open_dict(task_cfg):
+                del task_cfg["hydra"]
         ret.cfg = task_cfg
         ret.hydra_cfg = OmegaConf.create({"hydra": HydraConfig.get()})
         overrides = OmegaConf.to_container(config.hydra.overrides.task)


Test patch:
diff --git a/tests/test_examples/__init__.py b/tests/test_examples/__init__.py
--- a/tests/test_examples/__init__.py
+++ b/tests/test_examples/__init__.py
@@ -9,6 +9,3 @@ def run_with_error(cmd: Any, env: Any = None) -> str:
         err = stderr.decode("utf-8").rstrip().replace("\r\n", "\n")
         assert p.returncode == 1
     return err
-
-
-# TODO: test examples/patterns
diff --git a/tests/test_examples/test_patterns.py b/tests/test_examples/test_patterns.py
--- a/tests/test_examples/test_patterns.py
+++ b/tests/test_examples/test_patterns.py
@@ -1,4 +1,5 @@
 # Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved
+import re
 import sys
 from pathlib import Path
 from subprocess import check_output
@@ -12,6 +13,7 @@
     chdir_hydra_root,
     verify_dir_outputs,
 )
+from tests.test_examples import run_with_error
 
 chdir_hydra_root()
 
@@ -94,3 +96,16 @@ def test_objects_example(
         assert task.job_ret is not None
         assert task.job_ret.cfg == output_conf
         verify_dir_outputs(task.job_ret, overrides=task.overrides)
+
+
+def test_frozen(tmpdir: Any) -> None:
+    cmd = [
+        sys.executable,
+        "examples/patterns/write_protect_config_node/frozen.py",
+        "hydra.run.dir=" + str(tmpdir),
+        "data_bits=10",
+    ]
+
+    expected = """Error merging override data_bits=10\nCannot change read-only config container"""
+    err = run_with_error(cmd)
+    assert re.search(re.escape(expected), err) is not None