Repository: dbt-labs/dbt-core
Issue: backtrace is printed to the CLI if exception is raised before logger is initialized

I didn't catch this originally, but it looks like error logging behavior changed during the configuration object PR. Specifically, this line: https://github.com/fishtown-analytics/dbt/blame/dev/guion-bluford/dbt/main.py#L96

We definitely shouldn't throw away the backtrace if logging isn't yet initialized, but I think we can do better than printing a backtrace to the terminal. I'm curious if we can either initialize logging earlier, or suppress backtraces for exceptions raised by dbt directly, like validation errors. I'm open to keeping backtraces for unhandled exceptions

Changed files:
dbt/task/debug.py, dbt/main.py, dbt/exceptions.py, dbt/utils.py, dbt/config.py, test/unit/test_config.py

diff --git a/dbt/config.py b/dbt/config.py
index cadb7d841..023b188e5 100644
--- a/dbt/config.py
+++ b/dbt/config.py
@@ -10,6 +10,7 @@ import dbt.utils
 from dbt.contracts.connection import Connection, create_credentials
 from dbt.contracts.project import Project as ProjectContract, Configuration, \\
     PackageConfig, ProfileConfig
+from dbt.exceptions import DbtProjectError, DbtProfileError
 from dbt.context.common import env_var
 from dbt import compat
 from dbt.adapters.factory import get_relation_class_by_name
@@ -43,21 +44,6 @@ defined in your profiles.yml file. You can find profiles.yml here:
 """.format(profiles_file=DEFAULT_PROFILES_DIR)
 
 
-class DbtConfigError(Exception):
-    def __init__(self, message, project=None, result_type='invalid_project'):
-        self.project = project
-        super(DbtConfigError, self).__init__(message)
-        self.result_type = result_type
-
-
-class DbtProjectError(DbtConfigError):
-    pass
-
-
-class DbtProfileError(DbtConfigError):
-    pass
-
-
 def read_profile(profiles_dir):
     path = os.path.join(profiles_dir, 'profiles.yml')
 
diff --git a/dbt/exceptions.py b/dbt/exceptions.py
index e28011dd1..511be3fc4 100644
--- a/dbt/exceptions.py
+++ b/dbt/exceptions.py
@@ -129,6 +129,21 @@ class DependencyException(Exception):
     pass
 
 
+class DbtConfigError(RuntimeException):
+    def __init__(self, message, project=None, result_type='invalid_project'):
+        self.project = project
+        super(DbtConfigError, self).__init__(message)
+        self.result_type = result_type
+
+
+class DbtProjectError(DbtConfigError):
+    pass
+
+
+class DbtProfileError(DbtConfigError):
+    pass
+
+
 class SemverException(Exception):
     def __init__(self, msg=None):
         self.msg = msg
diff --git a/dbt/main.py b/dbt/main.py
index 126b9d0b8..d56be5b87 100644
--- a/dbt/main.py
+++ b/dbt/main.py
@@ -29,6 +29,7 @@ from dbt.utils import ExitCodes
 from dbt.config import Project, RuntimeConfig, DbtProjectError, \\
     DbtProfileError, DEFAULT_PROFILES_DIR, read_config, \\
     send_anonymous_usage_stats, colorize_output, read_profiles
+from dbt.exceptions import DbtProfileError, DbtProfileError, RuntimeException
 
 
 PROFILES_HELP_MESSAGE = """
@@ -92,7 +93,10 @@ def main(args=None):
 
         if logger_initialized:
             logger.debug(traceback.format_exc())
-        else:
+        elif not isinstance(e, RuntimeException):
+            # if it did not come from dbt proper and the logger is not
+            # initialized (so there's no safe path to log to), log the stack
+            # trace at error level.
             logger.error(traceback.format_exc())
         exit_code = ExitCodes.UnhandledError
 
@@ -152,7 +156,7 @@ def run_from_args(parsed):
     else:
         nearest_project_dir = get_nearest_project_dir()
         if nearest_project_dir is None:
-            raise RuntimeError(
+            raise RuntimeException(
                 "fatal: Not a dbt project (or any of the parent directories). "
                 "Missing dbt_project.yml file"
             )
@@ -161,7 +165,7 @@ def run_from_args(parsed):
 
         res = invoke_dbt(parsed)
         if res is None:
-            raise RuntimeError("Could not run dbt")
+            raise RuntimeException("Could not run dbt")
         else:
             task, cfg = res
 
diff --git a/dbt/task/debug.py b/dbt/task/debug.py
index 2d521371e..696799883 100644
--- a/dbt/task/debug.py
+++ b/dbt/task/debug.py
@@ -3,6 +3,7 @@ import pprint
 from dbt.logger import GLOBAL_LOGGER as logger
 import dbt.clients.system
 import dbt.config
+import dbt.exceptions
 
 from dbt.task.base_task import BaseTask
 
@@ -30,13 +31,13 @@ class DebugTask(BaseTask):
         try:
             project = dbt.config.Project.from_current_directory()
             project_profile = project.profile_name
-        except dbt.config.DbtConfigError as exc:
+        except dbt.exceptions.DbtConfigError as exc:
             project = 'ERROR loading project: {!s}'.format(exc)
 
         # log the profile we decided on as well, if it's available.
         try:
             profile = dbt.config.Profile.from_args(self.args, project_profile)
-        except dbt.config.DbtConfigError as exc:
+        except dbt.exceptions.DbtConfigError as exc:
             profile = 'ERROR loading profile: {!s}'.format(exc)
 
         logger.info("args: {}".format(self.args))
diff --git a/dbt/utils.py b/dbt/utils.py
index f2d65d678..8c18482de 100644
--- a/dbt/utils.py
+++ b/dbt/utils.py
@@ -171,7 +171,6 @@ def get_docs_macro_name(docs_name, with_prefix=True):
 def dependencies_for_path(config, module_path):
     """Given a module path, yield all dependencies in that path."""
     logger.debug("Loading dependency project from {}".format(module_path))
-    import dbt.config
     for obj in os.listdir(module_path):
         full_obj = os.path.join(module_path, obj)
 
@@ -183,7 +182,7 @@ def dependencies_for_path(config, module_path):
 
         try:
             yield config.new_project(full_obj)
-        except dbt.config.DbtProjectError as e:
+        except dbt.exceptions.DbtProjectError as e:
             logger.info(
                 "Error reading dependency project at {}".format(
                     full_obj)
diff --git a/test/unit/test_config.py b/test/unit/test_config.py
index 67b7cc26d..8de9980fb 100644
--- a/test/unit/test_config.py
+++ b/test/unit/test_config.py
@@ -10,6 +10,7 @@ import mock
 import yaml
 
 import dbt.config
+import dbt.exceptions
 from dbt.contracts.connection import PostgresCredentials, RedshiftCredentials
 from dbt.contracts.project import PackageConfig
 
@@ -250,7 +251,7 @@ class TestProfile(BaseConfigTest):
 
     def test_missing_type(self):
         del self.default_profile_data['default']['outputs']['postgres']['type']
-        with self.assertRaises(dbt.config.DbtProfileError) as exc:
+        with self.assertRaises(dbt.exceptions.DbtProfileError) as exc:
             profile = dbt.config.Profile.from_raw_profiles(
                 self.default_profile_data, 'default'
             )
@@ -260,7 +261,7 @@ class TestProfile(BaseConfigTest):
 
     def test_bad_type(self):
         self.default_profile_data['default']['outputs']['postgres']['type'] = 'invalid'
-        with self.assertRaises(dbt.config.DbtProfileError) as exc:
+        with self.assertRaises(dbt.exceptions.DbtProfileError) as exc:
             profile = dbt.config.Profile.from_raw_profiles(
                 self.default_profile_data, 'default'
             )
@@ -270,7 +271,7 @@ class TestProfile(BaseConfigTest):
 
     def test_invalid_credentials(self):
         del self.default_profile_data['default']['outputs']['postgres']['host']
-        with self.assertRaises(dbt.config.DbtProfileError) as exc:
+        with self.assertRaises(dbt.exceptions.DbtProfileError) as exc:
             profile = dbt.config.Profile.from_raw_profiles(
                 self.default_profile_data, 'default'
             )
@@ -280,7 +281,7 @@ class TestProfile(BaseConfigTest):
 
     def test_target_missing(self):
         del self.default_profile_data['default']['target']
-        with self.assertRaises(dbt.config.DbtProfileError) as exc:
+        with self.assertRaises(dbt.exceptions.DbtProfileError) as exc:
             profile = dbt.config.Profile.from_raw_profiles(
                 self.default_profile_data, 'default'
             )
@@ -288,7 +289,7 @@ class TestProfile(BaseConfigTest):
         self.assertIn('default', str(exc.exception))
 
     def test_profile_invalid_project(self):
-        with self.assertRaises(dbt.config.DbtProjectError) as exc:
+        with self.assertRaises(dbt.exceptions.DbtProjectError) as exc:
             profile = dbt.config.Profile.from_raw_profiles(
                 self.default_profile_data, 'invalid-profile'
             )
@@ -298,7 +299,7 @@ class TestProfile(BaseConfigTest):
         self.assertIn('invalid-profile', str(exc.exception))
 
     def test_profile_invalid_target(self):
-        with self.assertRaises(dbt.config.DbtProfileError) as exc:
+        with self.assertRaises(dbt.exceptions.DbtProfileError) as exc:
             profile = dbt.config.Profile.from_raw_profiles(
                 self.default_profile_data, 'default', target_override='nope',
             )
@@ -309,7 +310,7 @@ class TestProfile(BaseConfigTest):
         self.assertIn('- with-vars', str(exc.exception))
 
     def test_no_outputs(self):
-        with self.assertRaises(dbt.config.DbtProfileError) as exc:
+        with self.assertRaises(dbt.exceptions.DbtProfileError) as exc:
             profile = dbt.config.Profile.from_raw_profiles(
                 {'some-profile': {'target': 'blah'}}, 'some-profile'
             )
@@ -335,7 +336,7 @@ class TestProfile(BaseConfigTest):
     def test_invalid_env_vars(self):
         self.env_override['env_value_port'] = 'hello'
         with mock.patch.dict(os.environ, self.env_override):
-            with self.assertRaises(dbt.config.DbtProfileError) as exc:
+            with self.assertRaises(dbt.exceptions.DbtProfileError) as exc:
                 dbt.config.Profile.from_raw_profile_info(
                     self.default_profile_data['default'],
                     'default',
@@ -443,7 +444,7 @@ class TestProfileFile(BaseFileTest):
         self.assertEqual(profile, from_raw)
 
     def test_no_profile(self):
-        with self.assertRaises(dbt.config.DbtProjectError) as exc:
+        with self.assertRaises(dbt.exceptions.DbtProjectError) as exc:
             dbt.config.Profile.from_args(self.args)
         self.assertIn('no profile was specified', str(exc.exception))
 
@@ -661,14 +662,14 @@ class TestProject(BaseConfigTest):
 
     def test_invalid_project_name(self):
         self.default_project_data['name'] = 'invalid-project-name'
-        with self.assertRaises(dbt.config.DbtProjectError) as exc:
+        with self.assertRaises(dbt.exceptions.DbtProjectError) as exc:
             project = dbt.config.Project.from_project_config(
                 self.default_project_data
             )
         self.assertIn('invalid-project-name', str(exc.exception))
 
     def test_no_project(self):
-        with self.assertRaises(dbt.config.DbtProjectError) as exc:
+        with self.assertRaises(dbt.exceptions.DbtProjectError) as exc:
             dbt.config.Project.from_project_root(self.project_dir)
 
         self.assertIn('no dbt_project.yml', str(exc.exception))
@@ -690,7 +691,7 @@ class TestProjectFile(BaseFileTest):
 
     def test_with_invalid_package(self):
         self.write_packages({'invalid': ['not a package of any kind']})
-        with self.assertRaises(dbt.config.DbtProjectError) as exc:
+        with self.assertRaises(dbt.exceptions.DbtProjectError) as exc:
             dbt.config.Project.from_project_root(self.project_dir)
 
 
@@ -744,7 +745,7 @@ class TestRuntimeConfig(BaseConfigTest):
         profile = self.get_profile()
         # invalid - must be boolean
         profile.use_colors = None
-        with self.assertRaises(dbt.config.DbtProjectError):
+        with self.assertRaises(dbt.exceptions.DbtProjectError):
             dbt.config.RuntimeConfig.from_parts(project, profile, {})