mirror of
https://github.com/dbt-labs/dbt-core
synced 2025-12-17 19:31:34 +00:00
Allow dbt deps to run when vars lack defaults in dbt_project.yml (#12171)
* Allow dbt deps to run when vars lack defaults in dbt_project.yml * Added Changelog entry * fixed integration tests * fixed mypy error * Fix: Use strict var validation by default, lenient only for dbt deps to show helpful errors * Fixed Integration tests * fixed nit review comments * addressed review comments and cleaned up tests * addressed review comments and cleaned up tests
This commit is contained in:
committed by
GitHub
parent
bca2211246
commit
575bac3172
6
.changes/unreleased/Fixes-20251117-185025.yaml
Normal file
6
.changes/unreleased/Fixes-20251117-185025.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
kind: Fixes
|
||||
body: Allow dbt deps to run when vars lack defaults in dbt_project.yml
|
||||
time: 2025-11-17T18:50:25.759091+05:30
|
||||
custom:
|
||||
Author: 3loka
|
||||
Issue: "8913"
|
||||
@@ -291,8 +291,22 @@ def project(func):
|
||||
flags = ctx.obj["flags"]
|
||||
# TODO deprecations warnings fired from loading the project will lack
|
||||
# the project_id in the snowplow event.
|
||||
|
||||
# Determine if vars should be required during project loading.
|
||||
# Commands that don't need vars evaluated (like 'deps', 'clean')
|
||||
# should use lenient mode (require_vars=False) to allow missing vars.
|
||||
# Commands that validate or execute (like 'run', 'compile', 'build', 'debug') should use
|
||||
# strict mode (require_vars=True) to show helpful "Required var X not found" errors.
|
||||
# If adding more commands to lenient mode, update this condition.
|
||||
require_vars = flags.WHICH != "deps"
|
||||
|
||||
project = load_project(
|
||||
flags.PROJECT_DIR, flags.VERSION_CHECK, ctx.obj["profile"], flags.VARS, validate=True
|
||||
flags.PROJECT_DIR,
|
||||
flags.VERSION_CHECK,
|
||||
ctx.obj["profile"],
|
||||
flags.VARS,
|
||||
validate=True,
|
||||
require_vars=require_vars,
|
||||
)
|
||||
ctx.obj["project"] = project
|
||||
|
||||
|
||||
@@ -101,7 +101,10 @@ class DbtProjectYamlRenderer(BaseRenderer):
|
||||
_KEYPATH_HANDLERS = ProjectPostprocessor()
|
||||
|
||||
def __init__(
|
||||
self, profile: Optional[HasCredentials] = None, cli_vars: Optional[Dict[str, Any]] = None
|
||||
self,
|
||||
profile: Optional[HasCredentials] = None,
|
||||
cli_vars: Optional[Dict[str, Any]] = None,
|
||||
require_vars: bool = True,
|
||||
) -> None:
|
||||
# Generate contexts here because we want to save the context
|
||||
# object in order to retrieve the env_vars. This is almost always
|
||||
@@ -109,10 +112,19 @@ class DbtProjectYamlRenderer(BaseRenderer):
|
||||
# even when we don't have a profile.
|
||||
if cli_vars is None:
|
||||
cli_vars = {}
|
||||
# Store profile and cli_vars for creating strict context later
|
||||
self.profile = profile
|
||||
self.cli_vars = cli_vars
|
||||
|
||||
# By default, require vars (strict mode) for proper error messages.
|
||||
# Commands that don't need vars (like 'deps') should explicitly pass
|
||||
# require_vars=False for lenient loading.
|
||||
if profile:
|
||||
self.ctx_obj = TargetContext(profile.to_target_dict(), cli_vars)
|
||||
self.ctx_obj = TargetContext(
|
||||
profile.to_target_dict(), cli_vars, require_vars=require_vars
|
||||
)
|
||||
else:
|
||||
self.ctx_obj = BaseContext(cli_vars) # type:ignore
|
||||
self.ctx_obj = BaseContext(cli_vars, require_vars=require_vars) # type:ignore
|
||||
context = self.ctx_obj.to_dict()
|
||||
super().__init__(context)
|
||||
|
||||
|
||||
@@ -52,9 +52,10 @@ def load_project(
|
||||
profile: HasCredentials,
|
||||
cli_vars: Optional[Dict[str, Any]] = None,
|
||||
validate: bool = False,
|
||||
require_vars: bool = True,
|
||||
) -> Project:
|
||||
# get the project with all of the provided information
|
||||
project_renderer = DbtProjectYamlRenderer(profile, cli_vars)
|
||||
project_renderer = DbtProjectYamlRenderer(profile, cli_vars, require_vars=require_vars)
|
||||
project = Project.from_project_root(
|
||||
project_root, project_renderer, verify_version=version_check, validate=validate
|
||||
)
|
||||
@@ -267,7 +268,14 @@ class RuntimeConfig(Project, Profile, AdapterRequiredConfig):
|
||||
args,
|
||||
)
|
||||
flags = get_flags()
|
||||
project = load_project(project_root, bool(flags.VERSION_CHECK), profile, cli_vars)
|
||||
# For dbt deps, use lenient var validation to allow missing vars
|
||||
# For all other commands, use strict validation for helpful error messages
|
||||
# If command is not set (e.g., during test setup), default to strict mode
|
||||
# unless the command is explicitly "deps"
|
||||
require_vars = getattr(flags, "WHICH", None) != "deps"
|
||||
project = load_project(
|
||||
project_root, bool(flags.VERSION_CHECK), profile, cli_vars, require_vars=require_vars
|
||||
)
|
||||
return project, profile
|
||||
|
||||
# Called in task/base.py, in BaseTask.from_args
|
||||
|
||||
@@ -152,10 +152,12 @@ class Var:
|
||||
context: Mapping[str, Any],
|
||||
cli_vars: Mapping[str, Any],
|
||||
node: Optional[Resource] = None,
|
||||
require_vars: bool = True,
|
||||
) -> None:
|
||||
self._context: Mapping[str, Any] = context
|
||||
self._cli_vars: Mapping[str, Any] = cli_vars
|
||||
self._node: Optional[Resource] = node
|
||||
self._require_vars: bool = require_vars
|
||||
self._merged: Mapping[str, Any] = self._generate_merged()
|
||||
|
||||
def _generate_merged(self) -> Mapping[str, Any]:
|
||||
@@ -168,9 +170,11 @@ class Var:
|
||||
else:
|
||||
return "<Configuration>"
|
||||
|
||||
def get_missing_var(self, var_name: str) -> NoReturn:
|
||||
# TODO function name implies a non exception resolution
|
||||
raise RequiredVarNotFoundError(var_name, dict(self._merged), self._node)
|
||||
def get_missing_var(self, var_name: str) -> None:
|
||||
# Only raise an error if vars are _required_
|
||||
if self._require_vars:
|
||||
# TODO function name implies a non exception resolution
|
||||
raise RequiredVarNotFoundError(var_name, dict(self._merged), self._node)
|
||||
|
||||
def has_var(self, var_name: str) -> bool:
|
||||
return var_name in self._merged
|
||||
@@ -198,10 +202,11 @@ class BaseContext(metaclass=ContextMeta):
|
||||
_context_attrs_: Dict[str, Any]
|
||||
|
||||
# subclass is TargetContext
|
||||
def __init__(self, cli_vars: Dict[str, Any]) -> None:
|
||||
def __init__(self, cli_vars: Dict[str, Any], require_vars: bool = True) -> None:
|
||||
self._ctx: Dict[str, Any] = {}
|
||||
self.cli_vars: Dict[str, Any] = cli_vars
|
||||
self.env_vars: Dict[str, Any] = {}
|
||||
self.require_vars: bool = require_vars
|
||||
|
||||
def generate_builtins(self) -> Dict[str, Any]:
|
||||
builtins: Dict[str, Any] = {}
|
||||
@@ -307,7 +312,7 @@ class BaseContext(metaclass=ContextMeta):
|
||||
from events
|
||||
where event_type = '{{ var("event_type", "activation") }}'
|
||||
"""
|
||||
return Var(self._ctx, self.cli_vars)
|
||||
return Var(self._ctx, self.cli_vars, require_vars=self.require_vars)
|
||||
|
||||
@contextmember()
|
||||
def env_var(self, var: str, default: Optional[str] = None) -> str:
|
||||
|
||||
@@ -15,8 +15,8 @@ class ConfiguredContext(TargetContext):
|
||||
# subclasses are SchemaYamlContext, MacroResolvingContext, ManifestContext
|
||||
config: AdapterRequiredConfig
|
||||
|
||||
def __init__(self, config: AdapterRequiredConfig) -> None:
|
||||
super().__init__(config.to_target_dict(), config.cli_vars)
|
||||
def __init__(self, config: AdapterRequiredConfig, require_vars: bool = True) -> None:
|
||||
super().__init__(config.to_target_dict(), config.cli_vars, require_vars=require_vars)
|
||||
self.config = config
|
||||
|
||||
@contextproperty()
|
||||
|
||||
@@ -5,8 +5,10 @@ from dbt.context.base import BaseContext, contextproperty
|
||||
|
||||
class TargetContext(BaseContext):
|
||||
# subclass is ConfiguredContext
|
||||
def __init__(self, target_dict: Dict[str, Any], cli_vars: Dict[str, Any]):
|
||||
super().__init__(cli_vars=cli_vars)
|
||||
def __init__(
|
||||
self, target_dict: Dict[str, Any], cli_vars: Dict[str, Any], require_vars: bool = True
|
||||
):
|
||||
super().__init__(cli_vars=cli_vars, require_vars=require_vars)
|
||||
self.target_dict = target_dict
|
||||
|
||||
@contextproperty()
|
||||
|
||||
222
tests/functional/deps/test_deps_with_vars.py
Normal file
222
tests/functional/deps/test_deps_with_vars.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Test that dbt deps works when vars are used in dbt_project.yml without defaults.
|
||||
|
||||
The key behavior being tested:
|
||||
- dbt deps uses lenient mode (require_vars=False) and succeeds even with missing vars
|
||||
- dbt run/compile/build/debug use strict mode (require_vars=True) and show the right error messages
|
||||
|
||||
Expected behavior from reviewer's scenario:
|
||||
1. dbt deps succeeds (doesn't need vars)
|
||||
2. dbt run fails with error "Required var 'X' not found"
|
||||
3. dbt run --vars succeeds when vars provided
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from dbt.tests.util import run_dbt, update_config_file
|
||||
from dbt_common.exceptions import CompilationError
|
||||
|
||||
# Simple model for testing
|
||||
model_sql = """
|
||||
select 1 as id
|
||||
"""
|
||||
|
||||
|
||||
# Base class with common fixtures
|
||||
class VarTestingBase:
|
||||
"""Base class for var testing with common fixtures"""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def models(self):
|
||||
return {"test_model.sql": model_sql}
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def project_config_update(self):
|
||||
return {
|
||||
"models": {"test_project": {"+materialized": "{{ var('materialized_var', 'view') }}"}}
|
||||
}
|
||||
|
||||
|
||||
# Test 1: Happy path - deps with defaults
|
||||
class TestDepsSucceedsWithVarDefaults(VarTestingBase):
|
||||
"""Test that dbt deps succeeds when vars have default values"""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def project_config_update(self):
|
||||
# config: +dataset: "{{ var('my_dataset', 'default') }}"
|
||||
return {"models": {"test_project": {"+dataset": "dqm_{{ var('my_dataset', 'default') }}"}}}
|
||||
|
||||
def test_deps_succeeds(self, project):
|
||||
# run: dbt deps
|
||||
# assert: succeeds
|
||||
results = run_dbt(["deps"])
|
||||
assert results is None or results == []
|
||||
|
||||
|
||||
# Test 2: Happy path - run with defaults
|
||||
class TestRunSucceedsWithVarDefaults(VarTestingBase):
|
||||
"""Test that dbt run succeeds when vars have default values"""
|
||||
|
||||
def test_run_succeeds(self, project):
|
||||
# run: dbt run
|
||||
# assert: succeeds
|
||||
results = run_dbt(["run"])
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
# Test 3: Happy path - run with explicit vars
|
||||
class TestRunSucceedsWithExplicitVars(VarTestingBase):
|
||||
"""Test that dbt run succeeds when vars provided via --vars"""
|
||||
|
||||
def test_run_succeeds_with_vars(self, project):
|
||||
# run: dbt run --vars '{"my_var": "table"}'
|
||||
# assert: succeeds
|
||||
results = run_dbt(["run", "--vars", '{"materialized_var": "table"}'])
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
# Test 4: Run fails with the right error message
|
||||
class TestRunFailsWithMissingVar(VarTestingBase):
|
||||
"""Test dbt run fails with right error"""
|
||||
|
||||
def test_run_fails_with_error(self, project):
|
||||
# IN TEST: dynamically remove default
|
||||
update_config_file(
|
||||
{"models": {"test_project": {"+materialized": "{{ var('materialized_var') }}"}}},
|
||||
project.project_root,
|
||||
"dbt_project.yml",
|
||||
)
|
||||
|
||||
# run: dbt run
|
||||
# assert: fails with "Required var 'X' not found"
|
||||
try:
|
||||
run_dbt(["run"], expect_pass=False)
|
||||
assert False, "Expected run to fail with missing required var"
|
||||
except CompilationError as e:
|
||||
error_msg = str(e)
|
||||
# ✅ Verify error message
|
||||
assert "materialized_var" in error_msg, "Error should mention var name"
|
||||
assert (
|
||||
"Required var" in error_msg or "not found" in error_msg
|
||||
), "Error should say 'Required var' or 'not found'"
|
||||
|
||||
|
||||
# Test 5: compile also fails with the correct error
|
||||
class TestCompileFailsWithMissingVar(VarTestingBase):
|
||||
"""Test dbt compile fails with error for missing vars"""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def project_config_update(self):
|
||||
# config: start with simple hardcoded value (no var)
|
||||
return {"models": {"test_project": {"+materialized": "view"}}}
|
||||
|
||||
def test_compile_fails_with_error(self, project):
|
||||
# IN TEST: dynamically add var without default
|
||||
update_config_file(
|
||||
{"models": {"test_project": {"+materialized": "{{ var('compile_var_no_default') }}"}}},
|
||||
project.project_root,
|
||||
"dbt_project.yml",
|
||||
)
|
||||
|
||||
# run: dbt compile
|
||||
# assert: fails with "Required var 'X' not found"
|
||||
try:
|
||||
run_dbt(["compile"], expect_pass=False)
|
||||
assert False, "Expected compile to fail with missing var"
|
||||
except CompilationError as e:
|
||||
error_msg = str(e)
|
||||
assert "compile_var_no_default" in error_msg
|
||||
assert "Required var" in error_msg or "not found" in error_msg
|
||||
|
||||
|
||||
# Test 6: deps succeeds even when var missing
|
||||
class TestDepsSucceedsEvenWhenVarMissing(VarTestingBase):
|
||||
"""Test dbt deps succeeds even when var has no default"""
|
||||
|
||||
def test_deps_still_succeeds(self, project):
|
||||
# run: dbt deps (succeeds)
|
||||
results = run_dbt(["deps"])
|
||||
assert results is None or results == []
|
||||
|
||||
# IN TEST: modify config to remove var default
|
||||
update_config_file(
|
||||
{"models": {"test_project": {"+materialized": "{{ var('materialized_var') }}"}}},
|
||||
project.project_root,
|
||||
"dbt_project.yml",
|
||||
)
|
||||
|
||||
# run: dbt deps again (still succeeds - lenient mode)
|
||||
results = run_dbt(["deps"])
|
||||
assert results is None or results == []
|
||||
|
||||
# run: dbt run (fails - strict mode)
|
||||
try:
|
||||
run_dbt(["run"], expect_pass=False)
|
||||
assert False, "Expected run to fail with missing var"
|
||||
except CompilationError as e:
|
||||
error_msg = str(e)
|
||||
assert "materialized_var" in error_msg
|
||||
assert "Required var" in error_msg or "not found" in error_msg
|
||||
|
||||
|
||||
# Test 7: build also fails
|
||||
class TestBuildFailsWithMissingVar(VarTestingBase):
|
||||
"""Test dbt build fails with error for missing vars"""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def project_config_update(self):
|
||||
# config: start with simple hardcoded value (no var)
|
||||
return {"models": {"test_project": {"+materialized": "view"}}}
|
||||
|
||||
def test_build_fails_with_error(self, project):
|
||||
# IN TEST: dynamically add var without default
|
||||
update_config_file(
|
||||
{"models": {"test_project": {"+materialized": "{{ var('build_var_no_default') }}"}}},
|
||||
project.project_root,
|
||||
"dbt_project.yml",
|
||||
)
|
||||
|
||||
# run: dbt build
|
||||
# assert: fails with "Required var 'X' not found"
|
||||
try:
|
||||
run_dbt(["build"], expect_pass=False)
|
||||
assert False, "Expected build to fail with missing var"
|
||||
except CompilationError as e:
|
||||
error_msg = str(e)
|
||||
assert "build_var_no_default" in error_msg
|
||||
assert "Required var" in error_msg or "not found" in error_msg
|
||||
|
||||
|
||||
# Test 8: debug with defaults
|
||||
class TestDebugSucceedsWithVarDefaults(VarTestingBase):
|
||||
"""Test dbt debug succeeds when vars have defaults (no regression)"""
|
||||
|
||||
def test_debug_succeeds(self, project):
|
||||
# run: dbt debug
|
||||
# assert: succeeds (no regression)
|
||||
run_dbt(["debug"])
|
||||
|
||||
|
||||
# Test 9: debug fails like run/compile (strict mode)
|
||||
class TestDebugFailsWithMissingVar(VarTestingBase):
|
||||
"""Test dbt debug fails with error (strict mode like run/compile)"""
|
||||
|
||||
def test_debug_fails_with_error(self, project):
|
||||
# First verify debug works with default
|
||||
run_dbt(["debug"])
|
||||
|
||||
# IN TEST: dynamically remove default
|
||||
update_config_file(
|
||||
{"models": {"test_project": {"+materialized": "{{ var('materialized_var') }}"}}},
|
||||
project.project_root,
|
||||
"dbt_project.yml",
|
||||
)
|
||||
|
||||
# run: dbt debug
|
||||
# assert: fails with "Required var 'X' not found"
|
||||
try:
|
||||
run_dbt(["debug"], expect_pass=False)
|
||||
assert False, "Expected debug to fail with missing var"
|
||||
except CompilationError as e:
|
||||
error_msg = str(e)
|
||||
assert "materialized_var" in error_msg
|
||||
assert "Required var" in error_msg or "not found" in error_msg
|
||||
74
tests/unit/config/test_renderer_with_vars.py
Normal file
74
tests/unit/config/test_renderer_with_vars.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Unit tests for rendering dbt_project.yml with vars without defaults."""
|
||||
|
||||
import pytest
|
||||
|
||||
from dbt.config.renderer import DbtProjectYamlRenderer
|
||||
from dbt.context.base import BaseContext
|
||||
|
||||
|
||||
class TestRendererWithRequiredVars:
|
||||
"""Test that DbtProjectYamlRenderer doesn't raise errors for missing vars"""
|
||||
|
||||
def test_base_context_with_require_vars_false(self):
|
||||
"""Test that BaseContext with require_vars=False returns None for missing vars"""
|
||||
context = BaseContext(cli_vars={}, require_vars=False)
|
||||
var_func = context.var
|
||||
|
||||
# Missing var should return None when require_vars=False
|
||||
assert var_func("missing_var") is None
|
||||
|
||||
# Missing var with default should return the default
|
||||
assert var_func("missing_var", "default_value") == "default_value"
|
||||
|
||||
# Existing var should return the value
|
||||
context2 = BaseContext(cli_vars={"existing_var": "value"}, require_vars=False)
|
||||
var_func2 = context2.var
|
||||
assert var_func2("existing_var") == "value"
|
||||
|
||||
def test_base_context_with_require_vars_true_raises_error(self):
|
||||
"""Test that BaseContext with require_vars=True raises error for missing vars"""
|
||||
from dbt.exceptions import RequiredVarNotFoundError
|
||||
|
||||
context = BaseContext(cli_vars={}, require_vars=True)
|
||||
var_func = context.var
|
||||
|
||||
# Missing var should raise error when require_vars=True
|
||||
with pytest.raises(RequiredVarNotFoundError):
|
||||
var_func("missing_var")
|
||||
|
||||
def test_dbt_project_yaml_renderer_doesnt_fail_on_missing_vars(self):
|
||||
"""Test that DbtProjectYamlRenderer with require_vars=False can render configs with missing vars"""
|
||||
# Pass require_vars=False to enable lenient mode (used by dbt deps)
|
||||
renderer = DbtProjectYamlRenderer(profile=None, cli_vars={}, require_vars=False)
|
||||
|
||||
# This project config uses a var without a default value
|
||||
project_dict = {
|
||||
"name": "test_project",
|
||||
"version": "1.0",
|
||||
"models": {"test_project": {"+dataset": "dqm_{{ var('my_dataset') }}"}},
|
||||
}
|
||||
|
||||
# This should not raise an error in lenient mode
|
||||
rendered = renderer.render_data(project_dict)
|
||||
|
||||
# The var should be rendered as None (which becomes "dqm_None" in the string)
|
||||
assert "models" in rendered
|
||||
assert "test_project" in rendered["models"]
|
||||
# When var returns None, it gets stringified in the template
|
||||
assert rendered["models"]["test_project"]["+dataset"] == "dqm_None"
|
||||
|
||||
def test_dbt_project_yaml_renderer_with_provided_var(self):
|
||||
"""Test that DbtProjectYamlRenderer works correctly when var is provided"""
|
||||
renderer = DbtProjectYamlRenderer(profile=None, cli_vars={"my_dataset": "prod"})
|
||||
|
||||
project_dict = {
|
||||
"name": "test_project",
|
||||
"version": "1.0",
|
||||
"models": {"test_project": {"+dataset": "dqm_{{ var('my_dataset') }}"}},
|
||||
}
|
||||
|
||||
# This should render correctly with the provided var
|
||||
rendered = renderer.render_data(project_dict)
|
||||
|
||||
# The var should be properly rendered
|
||||
assert rendered["models"]["test_project"]["+dataset"] == "dqm_prod"
|
||||
Reference in New Issue
Block a user