Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.2.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
# - id: ruff
# args: [--fix]
- id: ruff-format
10 changes: 6 additions & 4 deletions dbterd/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ def __get_selection(self, **kwargs) -> list[str]:
exclude_rules=kwargs.get("exclude"),
)

def __read_manifest(self, mp: str, mv: Optional[int] = None):
def __read_manifest(self, mp: str, mv: Optional[int] = None, bypass_validation: bool = False):
"""
Read the Manifest content.

Expand All @@ -160,9 +160,9 @@ def __read_manifest(self, mp: str, mv: Optional[int] = None):
cli_messaging.check_existence(mp, self.filename_manifest)
conditional = f" or provided version {mv} is incorrect" if mv else ""
with cli_messaging.handle_read_errors(self.filename_manifest, conditional):
return file_handlers.read_manifest(path=mp, version=mv)
return file_handlers.read_manifest(path=mp, version=mv, enable_compat_patch=bypass_validation)

def __read_catalog(self, cp: str, cv: Optional[int] = None):
def __read_catalog(self, cp: str, cv: Optional[int] = None, bypass_validation: bool = False):
"""
Read the Catalog content.

Expand All @@ -176,7 +176,7 @@ def __read_catalog(self, cp: str, cv: Optional[int] = None):
"""
cli_messaging.check_existence(cp, self.filename_catalog)
with cli_messaging.handle_read_errors(self.filename_catalog):
return file_handlers.read_catalog(path=cp, version=cv)
return file_handlers.read_catalog(path=cp, version=cv, enable_compat_patch=bypass_validation)

def __get_operation(self, kwargs):
"""
Expand Down Expand Up @@ -244,10 +244,12 @@ def __run_by_strategy(self, node_unique_id: Optional[str] = None, **kwargs) -> t
manifest = self.__read_manifest(
mp=kwargs.get("artifacts_dir"),
mv=kwargs.get("manifest_version"),
bypass_validation=kwargs.get("bypass_validation"),
)
catalog = self.__read_catalog(
cp=kwargs.get("artifacts_dir"),
cv=kwargs.get("catalog_version"),
bypass_validation=kwargs.get("bypass_validation"),
)

if node_unique_id:
Expand Down
10 changes: 7 additions & 3 deletions dbterd/adapters/dbt_core/dbt_invocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
from dbterd.helpers.log import logger


try:
from dbt.cli.main import dbtRunner as DbtRunner
except ImportError:
DbtRunner = None


class DbtInvocation:
"""Runner of dbt (https://docs.getdbt.com/reference/programmatic-invocations)."""

Expand All @@ -22,9 +28,7 @@ def __init__(self, dbt_project_dir: Optional[str] = None, dbt_target: Optional[s

"""
self.__ensure_dbt_installed()
from dbt.cli.main import dbtRunner

self.dbt = dbtRunner()
self.dbt = DbtRunner()
self.project_dir = dbt_project_dir or os.environ.get("DBT_PROJECT_DIR") or str(Path.cwd())
self.target = dbt_target
self.args = ["--quiet", "--log-level", "none"]
Expand Down
7 changes: 7 additions & 0 deletions dbterd/cli/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ def run_params(func):
default=None,
type=click.STRING,
)
@click.option(
"--bypass-validation",
help="Flag to bypass the Pydantic Validation Error by patching extra to ignored fields",
is_flag=True,
default=False,
show_default=True,
)
@click.option(
"--catalog-version",
"-cv",
Expand Down
50 changes: 47 additions & 3 deletions dbterd/helpers/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,40 @@ def open_json(fp):
return json.loads(load_file_contents(fp))


def patch_parser_compatibility(artifact: str = "catalog", artifact_version: Optional[int] = None) -> None:
"""
Conditionally monkey patch dbt-artifacts-parser Pydantic models for compatibility.

Modifies Metadata model configurations to use 'extra=ignore' instead of 'extra=forbid'
to handle fields added in newer dbt versions that aren't yet supported by the parser.

Args:
artifact: Artifact type ('manifest' or 'catalog'). Defaults to 'catalog'.
artifact_version: Artifact schema version (e.g., 12 for v12). Required for patching.

References:
https://github.com/yu-iskw/dbt-artifacts-parser/issues/160
"""
try:
artifact_module = __import__(
f"dbt_artifacts_parser.parsers.{artifact}.{artifact}_v{artifact_version}",
fromlist=["Metadata"],
)
metadata_class = getattr(artifact_module, "Metadata", None)

if metadata_class and hasattr(metadata_class, "model_config"):
metadata_class.model_config["extra"] = "ignore"
metadata_class.model_rebuild(force=True)

artifact_class_name = f"{artifact.capitalize()}V{artifact_version}"
artifact_class = getattr(artifact_module, artifact_class_name, None)
if artifact_class and hasattr(artifact_class, "model_rebuild"):
artifact_class.model_rebuild(force=True)

except (ImportError, AttributeError) as e:
logger.debug(f"Could not patch {artifact} v{artifact_version} metadata: {e}")


def convert_path(path: str) -> str:
"""
Convert a path which might be >260 characters long, to one that will be writable/readable on Windows.
Expand Down Expand Up @@ -113,18 +147,23 @@ def win_prepare_path(path: str) -> str: # pragma: no cover
return path


def read_manifest(path: str, version: Optional[int] = None) -> Manifest:
def read_manifest(path: str, version: Optional[int] = None, enable_compat_patch: bool = False) -> Manifest:
"""
Reads in the manifest.json file, with optional version specification.

Args:
path (str): manifest.json file path
version (int, optional): Manifest version. Defaults to None.
version (int, optional): Manifest version. Defaults to None (auto-detect).
enable_compat_patch (bool, optional): Enable compatibility monkey patching. Defaults to True.

Returns:
dict: Manifest dict

"""
if enable_compat_patch and version:
logger.info(f"Patching manifest v{version} for compatibility...")
patch_parser_compatibility(artifact="manifest", artifact_version=version)

_dict = open_json(f"{path}/manifest.json")
default_parser = "parse_manifest"
parser_version = f"parse_manifest_v{version}" if version else default_parser
Expand All @@ -140,18 +179,23 @@ def read_manifest(path: str, version: Optional[int] = None) -> Manifest:
return parse_func(manifest=_dict)


def read_catalog(path: str, version: Optional[int] = None) -> Catalog:
def read_catalog(path: str, version: Optional[int] = None, enable_compat_patch: bool = False) -> Catalog:
"""
Reads in the catalog.json file, with optional version specification.

Args:
path (str): catalog.json file path
version (int, optional): Catalog version. Defaults to None.
enable_compat_patch (bool, optional): Enable compatibility monkey patching. Defaults to True.

Returns:
dict: Catalog dict

"""
if enable_compat_patch and version:
logger.info(f"Patching catalog v{version} for compatibility...")
patch_parser_compatibility(artifact="catalog", artifact_version=version)

_dict = open_json(f"{path}/catalog.json")
default_parser = "parse_catalog"
parser_version = f"parse_catalog_v{version}" if version else default_parser
Expand Down
1 change: 1 addition & 0 deletions dbterd/helpers/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def format(self, record):

logger = logging.getLogger("dbterd")
logger.setLevel(logging.DEBUG)
logger.propagate = False

if len(logger.handlers) == 0:
ch = logging.StreamHandler()
Expand Down
24 changes: 24 additions & 0 deletions docs/nav/guide/cli-references.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ Command to generate diagram-as-a-code file from dbt artifact files, optionally d
which known as /target directory
-mv, --manifest-version TEXT Specified dbt manifest.json version
-cv, --catalog-version TEXT Specified dbt catalog.json version
--bypass-validation Flag to bypass the Pydantic Validation Error
by patching extra to ignored fields
[default: False]
--dbt Flag to indicate the Selection to follow
dbt's one leveraging Programmatic Invocation
-dpd, --dbt-project-dir TEXT Specified dbt project directory path
Expand Down Expand Up @@ -431,6 +434,24 @@ Specified dbt catalog.json version
dbterd run -cv 7
```

### dbterd run --bypass-validation

Flag to bypass Pydantic validation errors by patching the parser to ignore extra fields.

This option is useful when working with newer dbt versions that introduce fields not yet supported by the `dbt-artifacts-parser` library. When enabled, the parser will ignore unknown fields instead of raising validation errors, allowing you to continue generating ERDs even with newer artifact schemas.

> Default to `False`

!!! info "When to use this option"
You might encounter Pydantic validation errors like `"Error: Could not open file 'catalog.json': File catalog.json is corrupted, please rebuild"` when using artifact files from newer dbt versions. In such cases, enabling this flag can help you work around compatibility issues while waiting for the parser library to catch up with the latest dbt schema changes. Just be aware that any unsupported fields won't be included in the generated ERD (which is usually fine since relationship information remains intact).

**Examples:**
=== "CLI"

```bash
dbterd run --bypass-validation -mv 12 -cv 1
```

### dbterd run --resource-type (-rt)

Specified dbt resource type(seed, model, source, snapshot).
Expand Down Expand Up @@ -763,6 +784,9 @@ Shows hidden configured values, which will help us to see what configs are passe
which known as /target directory
-mv, --manifest-version TEXT Specified dbt manifest.json version
-cv, --catalog-version TEXT Specified dbt catalog.json version
--bypass-validation Flag to bypass the Pydantic Validation Error
by patching extra to ignored fields
[default: False]
--dbt Flag to indicate the Selection to follow
dbt's one leveraging Programmatic Invocation
-dpd, --dbt-project-dir TEXT Specified dbt project directory path
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ classifiers = [
requires-python = ">=3.9"
dependencies = [
"click>=8.1.7", # CLI interface framework
"dbt-artifacts-parser>=0.7.0", # Parse dbt artifacts (manifest.json and catalog.json)
"dbt-artifacts-parser>=0.10.0", # Parse dbt artifacts (manifest.json and catalog.json) - requires 0.10.0+ for better dbt 1.10 support
"requests>=2.32.3", # HTTP requests for dbt Cloud API integration
]

Expand Down
13 changes: 5 additions & 8 deletions tests/unit/adapters/test_base.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import contextlib
from pathlib import Path
from unittest import mock

Expand Down Expand Up @@ -70,26 +71,22 @@ def test_worker(self):
assert worker.filename_catalog == "catalog.json"

def test___read_manifest(self):
import contextlib

worker = Executor(ctx=click.Context(command=click.BaseCommand("dummy")))
with contextlib.ExitStack() as stack:
mock_read_manifest = stack.enter_context(mock.patch("dbterd.helpers.file.read_manifest", return_value={}))
mock_check_existence = stack.enter_context(mock.patch("dbterd.helpers.cli_messaging.check_existence"))
assert worker._Executor__read_manifest(mp=Path.cwd()) == {}
mock_check_existence.assert_called_once_with(Path.cwd(), "manifest.json")
mock_read_manifest.assert_called_once_with(path=Path.cwd(), version=None)
mock_read_manifest.assert_called_once_with(path=Path.cwd(), version=None, enable_compat_patch=False)

def test___read_catalog(self):
import contextlib

worker = Executor(ctx=click.Context(command=click.BaseCommand("dummy")))
with contextlib.ExitStack() as stack:
mock_read_catalog = stack.enter_context(mock.patch("dbterd.helpers.file.read_catalog", return_value={}))
mock_check_existence = stack.enter_context(mock.patch("dbterd.helpers.cli_messaging.check_existence"))
assert worker._Executor__read_catalog(cp=Path.cwd()) == {}
mock_check_existence.assert_called_once_with(Path.cwd(), "catalog.json")
mock_read_catalog.assert_called_once_with(path=Path.cwd(), version=None)
mock_read_catalog.assert_called_once_with(path=Path.cwd(), version=None, enable_compat_patch=False)

@mock.patch("dbterd.adapters.base.DbtInvocation.get_selection", return_value="dummy")
def test__get_selection(self, mock_dbt_invocation):
Expand Down Expand Up @@ -285,8 +282,8 @@ def test__run_by_strategy__for_api_simple(

assert worker._Executor__run_by_strategy(node_unique_id="irr") == {"i": "irr"}
assert mock_parent.mock_calls == [
mock.call.mock_read_manifest(mp=None, mv=None),
mock.call.mock_read_catalog(cp=None, cv=None),
mock.call.mock_read_manifest(mp=None, mv=None, bypass_validation=None),
mock.call.mock_read_catalog(cp=None, cv=None, bypass_validation=None),
mock.call.mock_set_single_node_selection(manifest={}, node_unique_id="irr"),
mock.call.mock_get_operation({"api": True}),
mock.call.mock_dbml_run(manifest={}, catalog={}, **{"api": True}),
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/adapters/test_dbt_invocation.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,53 @@
import importlib
from importlib.machinery import ModuleSpec
import sys
from unittest import mock

import click
from dbt.cli.main import dbtRunnerResult
import pytest

from dbterd.adapters.dbt_core import dbt_invocation
from dbterd.adapters.dbt_core.dbt_invocation import DbtInvocation


class TestDbtInvocation:
def test_import_error_dbt_runner(self):
"""Test that ImportError when importing DbtRunner is handled gracefully."""
# Save original modules
orig_dbt_cli_main = sys.modules.get("dbt.cli.main")
orig_dbt_invocation = sys.modules.get("dbterd.adapters.dbt_core.dbt_invocation")

try:
# Remove the module from cache to force reimport
if "dbt.cli.main" in sys.modules:
del sys.modules["dbt.cli.main"]
if "dbterd.adapters.dbt_core.dbt_invocation" in sys.modules:
del sys.modules["dbterd.adapters.dbt_core.dbt_invocation"]

# Mock the import to raise ImportError
with mock.patch(
"builtins.__import__",
side_effect=lambda name, *args, **kwargs: (
(_ for _ in ()).throw(ImportError(f"No module named '{name}'"))
if name == "dbt.cli.main"
else importlib.__import__(name, *args, **kwargs)
),
):
# Import the module which should catch the ImportError
import dbterd.adapters.dbt_core.dbt_invocation as reloaded_module # noqa: PLC0415

# Verify that DbtRunner is None when import fails
assert reloaded_module.DbtRunner is None
finally:
# Restore original modules
if orig_dbt_cli_main:
sys.modules["dbt.cli.main"] = orig_dbt_cli_main
if orig_dbt_invocation:
sys.modules["dbterd.adapters.dbt_core.dbt_invocation"] = orig_dbt_invocation
# Reload to restore the normal state
importlib.reload(dbt_invocation)

@mock.patch("importlib.util.find_spec")
def test__ensure_dbt_installed__no_dbt_installed(self, mock_find_spec):
mock_find_spec.return_value = ModuleSpec(name="dbt", loader=None)
Expand Down
7 changes: 1 addition & 6 deletions tests/unit/cli/test_runner.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import contextlib
from unittest import mock

import click
Expand Down Expand Up @@ -54,8 +55,6 @@ def test_invoke_run_with_invalid_target(self, dbterd: DbterdRunner) -> None:
assert str(e) == (f"Could not find adapter target type {invalid_target}!")

def test_invoke_run_with_invalid_strategy(self, dbterd: DbterdRunner) -> None:
import contextlib

invalid_strategy = "invalid-strategy"
with contextlib.ExitStack() as stack:
mock_read_m = stack.enter_context(
Expand Down Expand Up @@ -85,8 +84,6 @@ def test_invoke_run_with_invalid_strategy(self, dbterd: DbterdRunner) -> None:
],
)
def test_invoke_run_ok(self, target, output, dbterd: DbterdRunner) -> None:
import contextlib

with contextlib.ExitStack() as stack:
mock_read_m = stack.enter_context(
mock.patch("dbterd.adapters.base.Executor._Executor__read_manifest", return_value=None)
Expand Down Expand Up @@ -119,8 +116,6 @@ def test_command_invalid_selection_rule(self, dbterd: DbterdRunner) -> None:
],
)
def test_invoke_run_failed_to_write_output(self, target, output, dbterd: DbterdRunner) -> None:
import contextlib

with contextlib.ExitStack() as stack:
mock_read_m = stack.enter_context(
mock.patch("dbterd.adapters.base.Executor._Executor__read_manifest", return_value=None)
Expand Down
3 changes: 1 addition & 2 deletions tests/unit/helpers/test_cli_messaging.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import contextlib
from pathlib import Path
from unittest import mock

Expand All @@ -19,8 +20,6 @@ def test_check_existence_invalid_file(self):

@mock.patch("dbterd.helpers.file.open_json")
def test_handle_read_errors(self, mock_file_open_json):
import contextlib

mock_file_open_json.return_value = "not json"
with contextlib.ExitStack() as stack:
stack.enter_context(pytest.raises(click.FileError))
Expand Down
Loading