diff --git a/sdk/python/feast/cli/dbt_import.py b/sdk/python/feast/cli/dbt_import.py index c2e78b45c82..16bd7812101 100644 --- a/sdk/python/feast/cli/dbt_import.py +++ b/sdk/python/feast/cli/dbt_import.py @@ -130,10 +130,7 @@ def import_command( try: parser = DbtManifestParser(manifest_path) parser.parse() - except FileNotFoundError as e: - click.echo(f"{Fore.RED}Error: {e}{Style.RESET_ALL}", err=True) - raise SystemExit(1) - except ValueError as e: + except (FileNotFoundError, ValueError, ImportError) as e: click.echo(f"{Fore.RED}Error: {e}{Style.RESET_ALL}", err=True) raise SystemExit(1) @@ -374,7 +371,7 @@ def list_command( try: parser = DbtManifestParser(manifest_path) parser.parse() - except (FileNotFoundError, ValueError) as e: + except (FileNotFoundError, ValueError, ImportError) as e: click.echo(f"{Fore.RED}Error: {e}{Style.RESET_ALL}", err=True) raise SystemExit(1) diff --git a/sdk/python/feast/dbt/parser.py b/sdk/python/feast/dbt/parser.py index 676a5a76f11..9314104c9c1 100644 --- a/sdk/python/feast/dbt/parser.py +++ b/sdk/python/feast/dbt/parser.py @@ -8,6 +8,7 @@ """ import json +from copy import deepcopy from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional @@ -79,6 +80,34 @@ def __init__(self, manifest_path: str): self._raw_manifest: Optional[Dict[str, Any]] = None self._parsed_manifest: Optional[Any] = None + @staticmethod + def _sanitize_supported_languages(manifest: Dict[str, Any]) -> Dict[str, Any]: + """ + Remove unsupported macro language values before typed parsing. + + dbt may emit values like "javascript" in `macros.*.supported_languages`, + while dbt-artifacts-parser currently accepts only "python" and "sql". + Since Feast only needs model metadata, dropping unknown values is safe. + """ + manifest_copy = deepcopy(manifest) + macros = manifest_copy.get("macros", {}) + if not isinstance(macros, dict): + return manifest_copy + + for macro in macros.values(): + if not isinstance(macro, dict): + continue + + supported_languages = macro.get("supported_languages") + if isinstance(supported_languages, list): + macro["supported_languages"] = [ + language + for language in supported_languages + if language in {"python", "sql"} + ] + + return manifest_copy + def parse(self) -> None: """ Load and parse the manifest.json file using dbt-artifacts-parser. @@ -108,7 +137,15 @@ def parse(self) -> None: from dbt_artifacts_parser.parser import parse_manifest assert self._raw_manifest is not None - self._parsed_manifest = parse_manifest(manifest=self._raw_manifest) + try: + self._parsed_manifest = parse_manifest(manifest=self._raw_manifest) + except Exception as e: + if "supported_languages" not in str(e): + raise + sanitized_manifest = self._sanitize_supported_languages( + self._raw_manifest + ) + self._parsed_manifest = parse_manifest(manifest=sanitized_manifest) except ImportError: raise ImportError( "dbt-artifacts-parser is required for dbt integration.\n" diff --git a/sdk/python/tests/integration/dbt/test_dbt_integration.py b/sdk/python/tests/integration/dbt/test_dbt_integration.py index 7231f31427b..09a3cb5e123 100644 --- a/sdk/python/tests/integration/dbt/test_dbt_integration.py +++ b/sdk/python/tests/integration/dbt/test_dbt_integration.py @@ -1025,6 +1025,16 @@ def test_codegen_online_false(self, parser): class TestDbtCli: """Test the `feast dbt import` and `feast dbt list` CLI commands.""" + @staticmethod + def _get_cli_output(result) -> str: + """Return combined stdout/stderr across Click versions.""" + stderr = getattr(result, "stderr", "") + if not stderr: + stderr_bytes = getattr(result, "stderr_bytes", b"") + if stderr_bytes: + stderr = stderr_bytes.decode("utf-8", errors="replace") + return result.output + stderr + def test_dbt_list_command(self, manifest_path): from click.testing import CliRunner @@ -1061,6 +1071,27 @@ def test_dbt_list_show_columns(self, manifest_path): assert "driver_id" in result.output assert "conv_rate" in result.output + def test_dbt_list_import_error(self, manifest_path, monkeypatch): + from click.testing import CliRunner + + from feast.cli.dbt_import import list_command + from feast.dbt.parser import DbtManifestParser + + def _raise_import_error(self): # pragma: no cover - behavior tested via CLI + raise ImportError( + "dbt-artifacts-parser is required for dbt integration.\n" + "Install with: pip install 'feast[dbt]' or pip install dbt-artifacts-parser" + ) + + monkeypatch.setattr(DbtManifestParser, "parse", _raise_import_error) + + runner = CliRunner() + result = runner.invoke(list_command, ["-m", manifest_path]) + output = self._get_cli_output(result) + assert result.exit_code == 1 + assert "dbt-artifacts-parser is required for dbt integration" in output + assert "feast[dbt]" in output + def test_dbt_import_dry_run(self, manifest_path): from click.testing import CliRunner @@ -1086,6 +1117,38 @@ def test_dbt_import_dry_run(self, manifest_path): assert result.exit_code == 0 assert "Dry run" in result.output + def test_dbt_import_import_error(self, manifest_path, monkeypatch): + from click.testing import CliRunner + + from feast.cli.dbt_import import import_command + from feast.dbt.parser import DbtManifestParser + + def _raise_import_error(self): # pragma: no cover - behavior tested via CLI + raise ImportError( + "dbt-artifacts-parser is required for dbt integration.\n" + "Install with: pip install 'feast[dbt]' or pip install dbt-artifacts-parser" + ) + + monkeypatch.setattr(DbtManifestParser, "parse", _raise_import_error) + + runner = CliRunner() + result = runner.invoke( + import_command, + [ + "-m", + manifest_path, + "-e", + "driver_id", + "--dry-run", + ], + obj={"CHDIR": ".", "FS_YAML_FILE": "feature_store.yaml"}, + catch_exceptions=False, + ) + output = self._get_cli_output(result) + assert result.exit_code == 1 + assert "dbt-artifacts-parser is required for dbt integration" in output + assert "feast[dbt]" in output + def test_dbt_import_output_codegen(self, manifest_path, tmp_path): from click.testing import CliRunner diff --git a/sdk/python/tests/unit/dbt/test_parser.py b/sdk/python/tests/unit/dbt/test_parser.py index 2e15f9863ee..af4a5c3c366 100644 --- a/sdk/python/tests/unit/dbt/test_parser.py +++ b/sdk/python/tests/unit/dbt/test_parser.py @@ -172,6 +172,53 @@ def test_parse_manifest_invalid_json(self, tmp_path): assert "Invalid JSON" in str(exc_info.value) + def test_parse_retries_with_sanitized_supported_languages( + self, tmp_path, monkeypatch + ): + """Test parser retries after removing unsupported macro languages.""" + manifest = { + "metadata": { + "dbt_schema_version": "https://schemas.getdbt.com/dbt/manifest/v12.json", + "dbt_version": "1.11.11", + }, + "nodes": {}, + "macros": { + "macro.dbt.materialization_function_default": { + "name": "materialization_function_default", + "supported_languages": ["python", "sql", "javascript"], + } + }, + } + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text(json.dumps(manifest)) + + class _ParsedManifest: + nodes = {} + metadata = None + + call_manifests = [] + + def _fake_parse_manifest(*, manifest): + call_manifests.append(manifest) + if len(call_manifests) == 1: + raise Exception("supported_languages Input should be 'python' or 'sql'") + return _ParsedManifest() + + monkeypatch.setattr( + "dbt_artifacts_parser.parser.parse_manifest", _fake_parse_manifest + ) + + parser = DbtManifestParser(str(manifest_path)) + parser.parse() + + assert len(call_manifests) == 2 + assert call_manifests[0]["macros"][ + "macro.dbt.materialization_function_default" + ]["supported_languages"] == ["python", "sql", "javascript"] + assert call_manifests[1]["macros"][ + "macro.dbt.materialization_function_default" + ]["supported_languages"] == ["python", "sql"] + def test_get_all_models(self, sample_manifest): """Test getting all models from manifest.""" parser = DbtManifestParser(str(sample_manifest))