Skip to content

Commit eb0bf25

Browse files
datnguyeil-dat
andauthored
feat: config file supported (#138)
* feat: config file supported * docs: add doc * fix: use Optional and list * chore: use logger.infor instead of click.echo * fix: update config template * chore: fix test warnings * docs: update docs to align impl * chore: update docs site components --------- Co-authored-by: Dat Nguyen <[email protected]>
1 parent 817bee0 commit eb0bf25

33 files changed

+2300
-583
lines changed

README.md

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,20 +52,30 @@ dbterd --version
5252
```
5353

5454
> [!TIP]
55-
> **For dbt-core v1.10.x Users**: If you encounter validation errors with artifacts, error message might be misleading as:
56-
>
57-
> `Error: Could not open file 'catalog.json': File catalog.json is corrupted, please rebuild`
58-
>
59-
> please use the bypass flag:
60-
> ```bash
61-
> dbterd run --bypass-validation -mv 12 -cv 1
62-
> ```
63-
> This workaround will become the default behavior in the next release ⚠️.
64-
>
65-
> **For older dbt-core versions**: Upgrade [`dbt-artifacts-parser`](https://github.com/yu-iskw/dbt-artifacts-parser) to support newer dbt-core versions:
55+
> **For dbt-core users**: It's highly recommended to keep [`dbt-artifacts-parser`](https://github.com/yu-iskw/dbt-artifacts-parser) updated to the latest version to support newer `dbt-core` versions and their [manifest/catalog json schemas](https://schemas.getdbt.com/):
6656
> ```bash
6757
> pip install dbt-artifacts-parser --upgrade
6858
> ```
59+
>
60+
> **Note**: `dbterd` now automatically bypasses Pydantic validation errors by default, which helps with compatibility when using newer dbt artifact schemas.
61+
62+
## ⚙️ Configuration Files
63+
64+
Tired of typing the same CLI arguments repeatedly? `dbterd` supports configuration files to streamline your workflow!
65+
66+
```bash
67+
# Initialize a configuration file
68+
dbterd init
69+
70+
# Now just run with your saved settings
71+
dbterd run
72+
```
73+
74+
**Supported formats:**
75+
- `.dbterd.yml` - YAML configuration (recommended)
76+
- `pyproject.toml` - Add `[tool.dbterd]` section to your existing Python project config
77+
78+
Learn more in the [Configuration Files Guide](https://dbterd.datnguyen.de/latest/nav/guide/configuration-file.html).
6979

7080
## 💡 Examples
7181

dbterd/adapters/base.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,12 +151,19 @@ def __read_manifest(self, mp: str, mv: Optional[int] = None, bypass_validation:
151151
152152
Args:
153153
mp (str): manifest.json json file path
154-
mv (int, optional): Manifest version. Defaults to None.
154+
mv (int, optional): Manifest version. Defaults to None (auto-detect).
155155
156156
Returns:
157157
dict: Manifest dict
158158
159159
"""
160+
# Auto-detect version if not provided
161+
if mv is None:
162+
detected_version = default.default_manifest_version(artifacts_dir=mp)
163+
if detected_version:
164+
mv = int(detected_version)
165+
logger.info(f"Auto-detected manifest version: {mv}")
166+
160167
cli_messaging.check_existence(mp, self.filename_manifest)
161168
conditional = f" or provided version {mv} is incorrect" if mv else ""
162169
with cli_messaging.handle_read_errors(self.filename_manifest, conditional):
@@ -168,12 +175,19 @@ def __read_catalog(self, cp: str, cv: Optional[int] = None, bypass_validation: b
168175
169176
Args:
170177
cp (str): catalog.json file path
171-
cv (int, optional): Catalog version. Defaults to None.
178+
cv (int, optional): Catalog version. Defaults to None (auto-detect).
172179
173180
Returns:
174181
dict: Catalog dict
175182
176183
"""
184+
# Auto-detect version if not provided
185+
if cv is None:
186+
detected_version = default.default_catalog_version(artifacts_dir=cp)
187+
if detected_version:
188+
cv = int(detected_version)
189+
logger.info(f"Auto-detected catalog version: {cv}")
190+
177191
cli_messaging.check_existence(cp, self.filename_catalog)
178192
with cli_messaging.handle_read_errors(self.filename_catalog):
179193
return file_handlers.read_catalog(path=cp, version=cv, enable_compat_patch=bypass_validation)

dbterd/adapters/dbt_cloud/query.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ def __init__(self) -> None:
1212
Initialize the required input:
1313
- Query directory.
1414
"""
15-
self.dir = f"{os.path.dirname(os.path.realpath(__file__))}/include"
15+
# Point to centralized include directory at dbterd/include/graphql_queries
16+
dbterd_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
17+
self.dir = f"{dbterd_root}/include/graphql_queries"
1618

1719
def take(self, file_path: Optional[str] = None, algo: Optional[str] = None) -> str:
1820
"""

dbterd/cli/config.py

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
from pathlib import Path
2+
import sys
3+
from typing import Any, Optional
4+
5+
from dbterd import default
6+
from dbterd.constants import (
7+
CONFIG_FILE_DBTERD_YML,
8+
CONFIG_FILE_PYPROJECT_TOML,
9+
CONFIG_TEMPLATE_DBT_CLOUD,
10+
CONFIG_TEMPLATE_DBT_CORE,
11+
)
12+
from dbterd.helpers.yaml import YamlParseError, load_yaml_text
13+
14+
15+
if sys.version_info >= (3, 11):
16+
import tomllib
17+
else: # pragma: no cover
18+
try:
19+
import tomli as tomllib
20+
except ImportError:
21+
tomllib = None # type: ignore
22+
23+
24+
class ConfigError(Exception):
25+
"""Exception raised for configuration file errors."""
26+
27+
pass
28+
29+
30+
def normalize_config_keys(config: dict[str, Any]) -> dict[str, Any]:
31+
"""Convert kebab-case keys to snake_case for Python compatibility.
32+
33+
Args:
34+
config: Configuration dictionary with kebab-case keys
35+
36+
Returns:
37+
Dictionary with snake_case keys
38+
"""
39+
normalized = {}
40+
for key, value in config.items():
41+
normalized_key = key.replace("-", "_")
42+
if isinstance(value, dict):
43+
normalized[normalized_key] = normalize_config_keys(value)
44+
else:
45+
normalized[normalized_key] = value
46+
return normalized
47+
48+
49+
def has_dbterd_section(toml_path: Path) -> bool:
50+
"""Check if pyproject.toml contains [tool.dbterd] section.
51+
52+
Args:
53+
toml_path: Path to pyproject.toml file
54+
55+
Returns:
56+
True if [tool.dbterd] section exists, False otherwise
57+
"""
58+
if tomllib is None:
59+
return False
60+
61+
try:
62+
with open(toml_path, "rb") as f:
63+
data = tomllib.load(f)
64+
return "tool" in data and "dbterd" in data.get("tool", {})
65+
except Exception:
66+
return False
67+
68+
69+
def load_yaml_config(config_path: Path) -> dict[str, Any]:
70+
"""Load configuration from .dbterd.yml file.
71+
72+
Args:
73+
config_path: Path to .dbterd.yml file
74+
75+
Returns:
76+
Configuration dictionary
77+
78+
Raises:
79+
ConfigError: If YAML file is invalid
80+
"""
81+
try:
82+
with open(config_path) as f:
83+
content = f.read()
84+
config = load_yaml_text(content, path=str(config_path))
85+
return config if config else {}
86+
except YamlParseError as e:
87+
raise ConfigError(f"Invalid YAML in {config_path}:\n{e}") from e
88+
except Exception as e:
89+
raise ConfigError(f"Failed to read config file {config_path}: {e}") from e
90+
91+
92+
def load_toml_config(config_path: Path) -> dict[str, Any]:
93+
"""Load configuration from pyproject.toml [tool.dbterd] section.
94+
95+
Args:
96+
config_path: Path to pyproject.toml file
97+
98+
Returns:
99+
Configuration dictionary from [tool.dbterd] section
100+
101+
Raises:
102+
ConfigError: If TOML file is invalid or tomllib is not available
103+
"""
104+
if tomllib is None:
105+
raise ConfigError("TOML support requires 'tomli' package for Python < 3.11. Install with: pip install tomli")
106+
107+
try:
108+
with open(config_path, "rb") as f:
109+
data = tomllib.load(f)
110+
config = data.get("tool", {}).get("dbterd", {})
111+
return config if config else {}
112+
except Exception as e:
113+
raise ConfigError(f"Invalid TOML in {config_path}: {e}") from e
114+
115+
116+
def find_config_file(start_dir: Optional[Path] = None) -> Optional[Path]:
117+
"""Search for dbterd configuration file in current directory only.
118+
119+
Searches in the following order:
120+
1. pyproject.toml with [tool.dbterd] section
121+
2. .dbterd.yml
122+
123+
Args:
124+
start_dir: Directory to search in. Defaults to current working directory.
125+
126+
Returns:
127+
Path to configuration file, or None if not found
128+
"""
129+
if start_dir is None:
130+
start_dir = Path.cwd()
131+
132+
search_dir = start_dir.resolve()
133+
134+
toml_config = search_dir / CONFIG_FILE_PYPROJECT_TOML
135+
if toml_config.exists() and has_dbterd_section(toml_config):
136+
return toml_config
137+
138+
yaml_config = search_dir / CONFIG_FILE_DBTERD_YML
139+
if yaml_config.exists():
140+
return yaml_config
141+
142+
return None
143+
144+
145+
def load_config(config_path: Optional[str] = None, start_dir: Optional[Path] = None) -> dict[str, Any]:
146+
"""Load dbterd configuration from file.
147+
148+
Supports both .dbterd.yml and pyproject.toml formats.
149+
Returns empty dict if no configuration file is found (graceful fallback).
150+
151+
Args:
152+
config_path: Explicit path to config file. If None, searches for config file.
153+
start_dir: Directory to start searching from. Defaults to current working directory.
154+
155+
Returns:
156+
Configuration dictionary with normalized (snake_case) keys
157+
158+
Raises:
159+
ConfigError: If config file is specified but cannot be loaded
160+
"""
161+
# If explicit path provided, use it
162+
if config_path:
163+
path = Path(config_path)
164+
if not path.exists():
165+
raise ConfigError(f"Configuration file not found: {config_path}")
166+
167+
if path.name.endswith(".yml") or path.name.endswith(".yaml"):
168+
config = load_yaml_config(path)
169+
elif path.name == CONFIG_FILE_PYPROJECT_TOML:
170+
config = load_toml_config(path)
171+
else:
172+
raise ConfigError(f"Unsupported configuration file format: {path.name}")
173+
else:
174+
# Search for config file
175+
found_path = find_config_file(start_dir=start_dir)
176+
if found_path is None:
177+
return {} # No config file found - graceful fallback
178+
179+
path = found_path
180+
if path.name.endswith(".yml") or path.name.endswith(".yaml"):
181+
config = load_yaml_config(path)
182+
elif path.name == CONFIG_FILE_PYPROJECT_TOML:
183+
config = load_toml_config(path)
184+
else:
185+
return {} # Unexpected file type - graceful fallback
186+
187+
# Normalize keys from kebab-case to snake_case
188+
return normalize_config_keys(config)
189+
190+
191+
def get_yaml_template(template_type: str = "dbt-core") -> str:
192+
"""Generate YAML configuration template with default values.
193+
194+
Args:
195+
template_type: Type of template to generate. Options: "dbt-core", "dbt-cloud"
196+
197+
Returns:
198+
Formatted YAML template string with default values
199+
"""
200+
# Select template file based on type (default: dbt-core)
201+
template_file = CONFIG_TEMPLATE_DBT_CLOUD if template_type == "dbt-cloud" else CONFIG_TEMPLATE_DBT_CORE
202+
template_path = Path(__file__).parent.parent / "include" / "config_templates" / template_file
203+
204+
with open(template_path) as f:
205+
template_content = f.read()
206+
207+
# Get default values
208+
resource_types = default.default_resource_types()
209+
resource_types_yaml = "\n".join([f" - {rt}" for rt in resource_types])
210+
211+
# Format template with default values
212+
return template_content.format(
213+
default_target=default.default_target(),
214+
default_output=default.default_output_path(),
215+
default_output_file_name=default.default_output_file_name() or "",
216+
default_artifact_path=default.default_artifact_path(),
217+
default_manifest_version=default.default_manifest_version() or "",
218+
default_catalog_version=default.default_catalog_version() or "",
219+
default_bypass_validation=str(default.default_bypass_validation()).lower(),
220+
default_algo=default.default_algo(),
221+
default_entity_name_format=default.default_entity_name_format(),
222+
default_omit_entity_name_quotes=str(default.default_omit_entity_name_quotes()).lower(),
223+
default_omit_columns=str(default.default_omit_columns()).lower(),
224+
default_dbt_project_dir=default.default_dbt_project_dir(),
225+
default_dbt=str(default.default_dbt()).lower(),
226+
default_dbt_auto_artifacts=str(default.default_dbt_auto_artifacts()).lower(),
227+
default_dbt_target=default.default_dbt_target() or "",
228+
default_dbt_cloud=str(default.default_dbt_cloud()).lower(),
229+
default_dbt_cloud_host_url=default.default_dbt_cloud_host_url(),
230+
default_dbt_cloud_api_version=default.default_dbt_cloud_api_version(),
231+
resource_types=resource_types_yaml,
232+
)

0 commit comments

Comments
 (0)