Repository: facebookresearch/hydra

Issue:
[Bug] config access errors in the code should include stack trace
When the user is making a config access error in the code the stack trace should include the offending file+line.


Discussion:

Resolving 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
@@ -140,6 +140,25 @@ def load_configuration(
         run_mode: RunMode,
         strict: Optional[bool] = None,
         from_shell: bool = True,
+    ) -> DictConfig:
+        try:
+            return self._load_configuration(
+                config_name=config_name,
+                overrides=overrides,
+                run_mode=run_mode,
+                strict=strict,
+                from_shell=from_shell,
+            )
+        except OmegaConfBaseException as e:
+            raise HydraException() from e
+
+    def _load_configuration(
+        self,
+        config_name: Optional[str],
+        overrides: List[str],
+        run_mode: RunMode,
+        strict: Optional[bool] = None,
+        from_shell: bool = True,
     ) -> DictConfig:
         if config_name is not None and not self.repository.config_exists(config_name):
             self.missing_config_error(
diff --git a/hydra/_internal/utils.py b/hydra/_internal/utils.py
--- a/hydra/_internal/utils.py
+++ b/hydra/_internal/utils.py
@@ -6,8 +6,10 @@
 import os
 import sys
 import warnings
+from dataclasses import dataclass
 from os.path import dirname, join, normpath, realpath
-from traceback import print_exc
+from traceback import print_exc, print_exception
+from types import FrameType
 from typing import (
     Any,
     Callable,
@@ -21,7 +23,6 @@
 )
 
 from omegaconf import DictConfig, OmegaConf, read_write
-from omegaconf.errors import OmegaConfBaseException
 
 from hydra._internal.config_search_path_impl import ConfigSearchPathImpl
 from hydra.core.config_search_path import ConfigSearchPath
@@ -195,16 +196,74 @@ def create_config_search_path(search_path_dir: Optional[str]) -> ConfigSearchPat
 def run_and_report(func: Any) -> Any:
     try:
         return func()
-    except (HydraException, OmegaConfBaseException) as ex:
+    except Exception as ex:
         if "HYDRA_FULL_ERROR" in os.environ and os.environ["HYDRA_FULL_ERROR"] == "1":
             print_exc()
         else:
-            sys.stderr.write(str(ex) + "\n")
-            if ex.__cause__ is not None:
-                sys.stderr.write(str(ex.__cause__))
+            if isinstance(ex, HydraException):
+                sys.stderr.write(str(ex) + os.linesep)
+                if ex.__cause__ is not None:
+                    sys.stderr.write(str(ex.__cause__) + os.linesep)
 
+                sys.stderr.write(os.linesep)
+            else:
+                # Custom printing that strips the Hydra related stack frames from the top
+                # And any omegaconf frames from the bottom.
+                # It is possible to add additional libraries to sanitize from the bottom later,
+                # maybe even make it configurable.
+                tb: Any = ex.__traceback__
+                search_max = 10
+                # strip Hydra frames from start of stack
+                # will strip until it hits run_job()
+                while search_max > 0:
+                    frame = tb.tb_frame
+                    tb = tb.tb_next
+                    search_max = search_max - 1
+                    if inspect.getframeinfo(frame).function == "run_job":
+                        break
+                if search_max == 0:
+                    sys.stderr.write("WARNING, failed to detect relevant stack start\n")
+                    tb = ex.__traceback__
+
+                # strip OmegaConf frames from bottom of stack
+                end = tb
+                num_frames = 0
+                while end is not None:
+                    frame = end.tb_frame
+                    mdl = inspect.getmodule(frame)
+                    assert mdl is not None
+                    name = mdl.__name__
+                    if name.startswith("omegaconf."):
+                        break
+                    end = end.tb_next
+                    num_frames = num_frames + 1
+
+                @dataclass
+                class FakeTracebackType:
+                    tb_next: Any = None  # Optional[FakeTracebackType]
+                    tb_frame: Optional[FrameType] = None
+                    tb_lasti: Optional[int] = None
+                    tb_lineno: Optional[int] = None
+
+                iter_tb = tb
+                final_tb = FakeTracebackType()
+                cur = final_tb
+                added = 0
+                while True:
+                    cur.tb_lasti = iter_tb.tb_lasti
+                    cur.tb_lineno = iter_tb.tb_lineno
+                    cur.tb_frame = iter_tb.tb_frame
+
+                    if added == num_frames - 1:
+                        break
+                    added = added + 1
+                    cur.tb_next = FakeTracebackType()
+                    cur = cur.tb_next
+                    iter_tb = iter_tb.tb_next
+
+                print_exception(etype=None, value=ex, tb=final_tb)  # type: ignore
             sys.stderr.write(
-                "\nSet the environment variable HYDRA_FULL_ERROR=1 for a complete stack trace.\n"
+                "Set the environment variable HYDRA_FULL_ERROR=1 for a complete stack trace.\n"
             )
         sys.exit(1)
 


Test patch:
diff --git a/tests/test_apps/app_with_runtime_config_error/config.yaml b/tests/test_apps/app_with_runtime_config_error/config.yaml
new file mode 100644
--- /dev/null
+++ b/tests/test_apps/app_with_runtime_config_error/config.yaml
@@ -0,0 +1,4 @@
+# @package _global_
+dataset:
+  name: imagenet
+  path: /datasets/imagenet
diff --git a/tests/test_apps/app_with_runtime_config_error/my_app.py b/tests/test_apps/app_with_runtime_config_error/my_app.py
new file mode 100644
--- /dev/null
+++ b/tests/test_apps/app_with_runtime_config_error/my_app.py
@@ -0,0 +1,17 @@
+# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved
+from omegaconf import DictConfig
+
+import hydra
+
+
+def foo(cfg: DictConfig) -> None:
+    cfg.foo = "bar"  # does not exist in the config
+
+
+@hydra.main(config_name="config")
+def my_app(cfg: DictConfig) -> None:
+    foo(cfg)
+
+
+if __name__ == "__main__":
+    my_app()
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
@@ -106,6 +106,5 @@ def test_frozen(tmpdir: Any) -> None:
         "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
+    assert re.search(re.escape("Error merging override data_bits=10"), err) is not None
diff --git a/tests/test_hydra.py b/tests/test_hydra.py
--- a/tests/test_hydra.py
+++ b/tests/test_hydra.py
@@ -879,7 +879,8 @@ def test_multirun_structured_conflict(
     ]
     cmd.extend(overrides)
     if error:
-        ret = run_with_error(cmd)
+        expected = normalize_newlines(expected)
+        ret = normalize_newlines(run_with_error(cmd))
         assert re.search(re.escape(expected), ret) is not None
     else:
         ret = normalize_newlines(
@@ -925,7 +926,6 @@ def test_run_with_missing_default(
     def test_command_line_interpolations_evaluated_lazily(
         self, cmd_base: List[str], tmpdir: Any
     ) -> None:
-
         cmd = cmd_base + [
             "hydra.sweep.dir=" + str(tmpdir),
             "+foo=10,20",
@@ -945,7 +945,6 @@ def test_command_line_interpolations_evaluated_lazily(
     def test_multirun_config_overrides_evaluated_lazily(
         self, cmd_base: List[str], tmpdir: Any
     ) -> None:
-
         cmd = cmd_base + [
             "hydra.sweep.dir=" + str(tmpdir),
             "+foo=10,20",
@@ -988,3 +987,26 @@ def test_run_pass_list(self, cmd_base: List[str], tmpdir: Any) -> None:
         expected = {"foo": [1, 2, 3]}
         ret = str(subprocess.check_output(cmd).decode("utf-8"))
         assert OmegaConf.create(ret) == OmegaConf.create(expected)
+
+
+def test_app_with_error_exception_sanitized(tmpdir: Any, monkeypatch: Any) -> None:
+    monkeypatch.chdir("tests/test_apps/app_with_runtime_config_error")
+    cmd = [
+        sys.executable,
+        "my_app.py",
+        "hydra.sweep.dir=" + str(tmpdir),
+    ]
+    expected = """Traceback (most recent call last):
+  File "my_app.py", line 13, in my_app
+    foo(cfg)
+  File "my_app.py", line 8, in foo
+    cfg.foo = "bar"  # does not exist in the config
+omegaconf.errors.ConfigAttributeError: Key 'foo' is not in struct
+\tfull_key: foo
+\treference_type=Optional[Dict[Any, Any]]
+\tobject_type=dict
+
+Set the environment variable HYDRA_FULL_ERROR=1 for a complete stack trace."""
+
+    ret = run_with_error(cmd)
+    assert normalize_newlines(expected) == normalize_newlines(ret)