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:
Trilok Ramakrishna
2025-11-26 10:38:37 +05:30
committed by GitHub
parent bca2211246
commit 575bac3172
9 changed files with 358 additions and 15 deletions

View 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"

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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()

View File

@@ -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()

View 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

View 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"