Repository: dbt-labs/dbt-core

Issue:
[CT-501] [Bug] UnsetProfileConfig str and repr raise RuntimeException
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

Imitating what's going on in [lib.py](https://github.com/dbt-labs/dbt-core/blob/main/core/dbt/lib.py#L10=), say I've created a test dbt project in my current dir:

```python
import os
from collections import namedtuple

from dbt.config.runtime UnsetProfileConfig

ConfigArgs = namedtuple("ConfigArgs", "project_dir profiles_dir single_threaded profile target")
my_project = "/path/to/my/test_dbt_project"
args = ConfigArgs(my_project, None,  False, "test_dbt_project", None)

config = UnsetProfileConfig.from_args(args)

# All of these raise a RuntimeException
print(config)  # Calls __str__ under the hood.
repr(config)
str(config)
```

A `RuntimeException` will be raised. See log outputs for tracebacks.

Ultimately, this is due to `UnsetProfileConfig` overriding`__getattribute__` to stop access to profile attributes that have not been set. Implementing `__str__` and `__repr__` for `UnsetProfileConfig` should fix this issue.

I understand that this is not a critical bug, as only developers messing around with dbt-as-a-library are likely to be trying to debug `UnsetProfileConfig` instances (There are dozens of us!). So, I'm more than willing to do the work and contribute a PR that mimics `Project.__str__` if that's the case and it's welcomed.


### Expected Behavior

A nice `repr` or `str` should be produced, like it happens for `RuntimeConfig`:

```python
from collections import namedtuple

from dbt.config.runtime RuntimeConfig

ConfigArgs = namedtuple("ConfigArgs", "project_dir profiles_dir single_threaded profile target")
my_project = "/path/to/my/test_dbt_project"
args = ConfigArgs(my_project, None,  False, "test_dbt_project", None)

config = RuntimeConfig.from_args(args)
print(config)
```

Outputs:
```python
{'name': 'test_dbt_project', 'version': '1.0.0', 'project-root': '/path/to/my/test_dbt_project', 'profile': 'test_dbt_project', 'model-paths': ['models'], 'macro-paths': ['macros'], 'seed-paths': ['seeds'], 'test-paths': ['tests'], 'analysis-paths': ['analyses'], 'docs-paths': ['models', 'seeds', 'snapshots', 'analyses', 'macros'], 'asset-paths': [], 'target-path': 'target', 'snapshot-paths': ['snapshots'], 'clean-targets': ['target', 'dbt_packages'], 'log-path': 'logs', 'quoting': {'database': True, 'schema': True, 'identifier': True}, 'models': {'test_dbt_project': {'+tags': ['schedule_interval:@hourly']}}, 'on-run-start': [], 'on-run-end': [], 'dispatch': [], 'seeds': {}, 'snapshots': {}, 'sources': {}, 'tests': {}, 'vars': {}, 'require-dbt-version': ['>=0.0.0'], 'config-version': 2, 'query-comment': {'comment': "\n{%- set comment_dict = {} -%}\n{%- do comment_dict.update(\n    app='dbt',\n    dbt_version=dbt_version,\n    profile_name=target.get('profile_name'),\n    target_name=target.get('target_name'),\n) -%}\n{%- if node is not none -%}\n  {%- do comment_dict.update(\n    node_id=node.unique_id,\n  ) -%}\n{% else %}\n  {# in the node context, the connection name is the node_id #}\n  {%- do comment_dict.update(connection_name=connection_name) -%}\n{%- endif -%}\n{{ return(tojson(comment_dict)) }}\n", 'append': False, 'job-label': False}, 'packages': []}
```


### Steps To Reproduce

1. Create a default dbt project with `dbt init`.
2. Fill up a profile with random data.
3. Start a Python interactive session and run the snippet pasted above by replacing the path to your test project, and profile name.

### Relevant log output

```shell
Here are a couple of tracebacks:


In [10]: repr(config)
---------------------------------------------------------------------------
RuntimeException                          Traceback (most recent call last)
Input In [10], in <cell line: 1>()
----> 1 repr(config)

File ~/.pyenv/versions/3.9.12/lib/python3.9/dataclasses.py:370, in _recursive_repr.<locals>.wrapper(self)
    368 repr_running.add(key)
    369 try:
--> 370     result = user_function(self)
    371 finally:
    372     repr_running.discard(key)

File <string>:3, in __repr__(self)

File ~/.pyenv/versions/3.9.12/envs/3.9ddf/lib/python3.9/site-packages/dbt/config/runtime.py:454, in UnsetProfileConfig.__getattribute__(self, name)
    451 def __getattribute__(self, name):
    452     # Override __getattribute__ to check that the attribute isn't 'banned'.
    453     if name in {'profile_name', 'target_name'}:
--> 454         raise RuntimeException(
    455             f'Error: disallowed attribute "{name}" - no profile!'
    456         )
    458     # avoid every attribute access triggering infinite recursion
    459     return RuntimeConfig.__getattribute__(self, name)

RuntimeException: Runtime Error
  Error: disallowed attribute "profile_name" - no profile!

In [11]: str(config)
---------------------------------------------------------------------------
RuntimeException                          Traceback (most recent call last)
Input In [11], in <cell line: 1>()
----> 1 str(config)

File ~/.pyenv/versions/3.9.12/envs/3.9ddf/lib/python3.9/site-packages/dbt/config/project.py:580, in Project.__str__(self)
    579 def __str__(self):
--> 580     cfg = self.to_project_config(with_packages=True)
    581     return str(cfg)

File ~/.pyenv/versions/3.9.12/envs/3.9ddf/lib/python3.9/site-packages/dbt/config/project.py:602, in Project.to_project_config(self, with_packages)
    590 def to_project_config(self, with_packages=False):
    591     """Return a dict representation of the config that could be written to
    592     disk with `yaml.safe_dump` to get this configuration.
    593 
   (...)
    596     :returns dict: The serialized profile.
    597     """
    598     result = deepcopy({
    599         'name': self.project_name,
    600         'version': self.version,
    601         'project-root': self.project_root,
--> 602         'profile': self.profile_name,
    603         'model-paths': self.model_paths,
    604         'macro-paths': self.macro_paths,
    605         'seed-paths': self.seed_paths,
    606         'test-paths': self.test_paths,
    607         'analysis-paths': self.analysis_paths,
    608         'docs-paths': self.docs_paths,
    609         'asset-paths': self.asset_paths,
    610         'target-path': self.target_path,
    611         'snapshot-paths': self.snapshot_paths,
    612         'clean-targets': self.clean_targets,
    613         'log-path': self.log_path,
    614         'quoting': self.quoting,
    615         'models': self.models,
    616         'on-run-start': self.on_run_start,
    617         'on-run-end': self.on_run_end,
    618         'dispatch': self.dispatch,
    619         'seeds': self.seeds,
    620         'snapshots': self.snapshots,
    621         'sources': self.sources,
    622         'tests': self.tests,
    623         'vars': self.vars.to_dict(),
    624         'require-dbt-version': [
    625             v.to_version_string() for v in self.dbt_version
    626         ],
    627         'config-version': self.config_version,
    628     })
    629     if self.query_comment:
    630         result['query-comment'] = \
    631             self.query_comment.to_dict(omit_none=True)

File ~/.pyenv/versions/3.9.12/envs/3.9ddf/lib/python3.9/site-packages/dbt/config/runtime.py:454, in UnsetProfileConfig.__getattribute__(self, name)
    451 def __getattribute__(self, name):
    452     # Override __getattribute__ to check that the attribute isn't 'banned'.
    453     if name in {'profile_name', 'target_name'}:
--> 454         raise RuntimeException(
    455             f'Error: disallowed attribute "{name}" - no profile!'
    456         )
    458     # avoid every attribute access triggering infinite recursion
    459     return RuntimeConfig.__getattribute__(self, name)

RuntimeException: Runtime Error
  Error: disallowed attribute "profile_name" - no profile!
```


### Environment

```markdown
- OS: Arch Linux
- Python: 3.9
- dbt: 1.0.4
```


### What database are you using dbt with?

postgres

### Additional Context

_No response_


Discussion:
Hey @tomasfarias , thanks for opening this issue and doing the deep dive into the code to find the solution.
It would be wonderful if you can contribute a PR. This would be super helpful for folks debugging `UnsetProfileConfig` as you pointed out. Let me know if there's anything I can help with!
Thanks for the reply @ChenyuLInx. I have some time this week so I'll open up one soon.

Resolving patch:
diff --git a/core/dbt/config/runtime.py b/core/dbt/config/runtime.py
--- a/core/dbt/config/runtime.py
+++ b/core/dbt/config/runtime.py
@@ -1,7 +1,7 @@
 import itertools
 import os
 from copy import deepcopy
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from pathlib import Path
 from typing import Dict, Any, Optional, Mapping, Iterator, Iterable, Tuple, List, MutableSet, Type
 
@@ -417,6 +417,9 @@ class UnsetProfileConfig(RuntimeConfig):
     missing, any access to profile members results in an exception.
     """
 
+    profile_name: str = field(repr=False)
+    target_name: str = field(repr=False)
+
     def __post_init__(self):
         # instead of futzing with InitVar overrides or rewriting __init__, just
         # `del` the attrs we don't want  users touching.
@@ -437,6 +440,56 @@ def to_target_dict(self):
         # re-override the poisoned profile behavior
         return DictDefaultEmptyStr({})
 
+    def to_project_config(self, with_packages=False):
+        """Return a dict representation of the config that could be written to
+        disk with `yaml.safe_dump` to get this configuration.
+
+        Overrides dbt.config.Project.to_project_config to omit undefined profile
+        attributes.
+
+        :param with_packages bool: If True, include the serialized packages
+            file in the root.
+        :returns dict: The serialized profile.
+        """
+        result = deepcopy(
+            {
+                "name": self.project_name,
+                "version": self.version,
+                "project-root": self.project_root,
+                "profile": "",
+                "model-paths": self.model_paths,
+                "macro-paths": self.macro_paths,
+                "seed-paths": self.seed_paths,
+                "test-paths": self.test_paths,
+                "analysis-paths": self.analysis_paths,
+                "docs-paths": self.docs_paths,
+                "asset-paths": self.asset_paths,
+                "target-path": self.target_path,
+                "snapshot-paths": self.snapshot_paths,
+                "clean-targets": self.clean_targets,
+                "log-path": self.log_path,
+                "quoting": self.quoting,
+                "models": self.models,
+                "on-run-start": self.on_run_start,
+                "on-run-end": self.on_run_end,
+                "dispatch": self.dispatch,
+                "seeds": self.seeds,
+                "snapshots": self.snapshots,
+                "sources": self.sources,
+                "tests": self.tests,
+                "vars": self.vars.to_dict(),
+                "require-dbt-version": [v.to_version_string() for v in self.dbt_version],
+                "config-version": self.config_version,
+            }
+        )
+        if self.query_comment:
+            result["query-comment"] = self.query_comment.to_dict(omit_none=True)
+
+        if with_packages:
+            result.update(self.packages.to_dict(omit_none=True))
+
+        return result
+
     @classmethod
     def from_parts(
         cls,


Test patch:
diff --git a/test/unit/test_config.py b/test/unit/test_config.py
--- a/test/unit/test_config.py
+++ b/test/unit/test_config.py
@@ -317,7 +317,7 @@ def test_extra_path(self):
             'model-paths': ['models'],
             'source-paths': ['other-models'],
         })
-        with self.assertRaises(dbt.exceptions.DbtProjectError) as exc:            
+        with self.assertRaises(dbt.exceptions.DbtProjectError) as exc:
             project = project_from_config_norender(self.default_project_data)
 
         self.assertIn('source-paths and model-paths', str(exc.exception))
@@ -1230,6 +1230,45 @@ def test_from_args(self):
         self.assertEqual(config.project_name, 'my_test_project')
 
 
+class TestUnsetProfileConfig(BaseConfigTest):
+    def setUp(self):
+        self.profiles_dir = '/invalid-profiles-path'
+        self.project_dir = '/invalid-root-path'
+        super().setUp()
+        self.default_project_data['project-root'] = self.project_dir
+
+    def get_project(self):
+        return project_from_config_norender(self.default_project_data, verify_version=self.args.version_check)
+
+    def get_profile(self):
+        renderer = empty_profile_renderer()
+        return dbt.config.Profile.from_raw_profiles(
+            self.default_profile_data, self.default_project_data['profile'], renderer
+        )
+
+    def test_str(self):
+        project = self.get_project()
+        profile = self.get_profile()
+        config = dbt.config.UnsetProfileConfig.from_parts(project, profile, {})
+
+        str(config)
+
+    def test_repr(self):
+        project = self.get_project()
+        profile = self.get_profile()
+        config = dbt.config.UnsetProfileConfig.from_parts(project, profile, {})
+
+        repr(config)
+
+    def test_to_project_config(self):
+        project = self.get_project()
+        profile = self.get_profile()
+        config = dbt.config.UnsetProfileConfig.from_parts(project, profile, {})
+        project_config = config.to_project_config()
+
+        self.assertEqual(project_config["profile"], "")
+
+
 class TestVariableRuntimeConfigFiles(BaseFileTest):
     def setUp(self):
         super().setUp()