Skip to content

Commit 01cea57

Browse files
sfc-gh-yudingclaude
andcommitted
feat: [SNOW-3444080] add env.yml support to snow dbt deploy
Adds three flags to `snow dbt deploy` that bring env.yml into the deploy flow alongside profiles.yml: - `--env-file-dir <path>`: inject env.yml from a separate directory into the staged project root, mirroring `--profiles-dir`. - `--default-environment <name>`: set DEFAULT_ENVIRONMENT on the DBT PROJECT object (CREATE / CREATE OR REPLACE / ALTER SET). - `--unset-default-environment`: clear DEFAULT_ENVIRONMENT on alter, mutually exclusive with --default-environment. env.yml is staged like profiles.yml: copied from the source directory by default, optionally overridden via `--env-file-dir`, and round-tripped through pyyaml so YAML comments don't leak onto the stage. The CLI does no client-side schema validation — the server enforces env-var prefixes, secret-key rules, and reserved environment names. Server support for these properties is gated by ENABLE_DBT_ENV_VARS (GS PR #404443). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 914ad24 commit 01cea57

7 files changed

Lines changed: 619 additions & 2 deletions

File tree

src/snowflake/cli/_plugins/dbt/commands.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from click import types
2222
from snowflake.cli._plugins.dbt.constants import (
2323
DBT_COMMANDS,
24+
ENV_FILENAME,
2425
OUTPUT_COLUMN_NAME,
2526
PROFILES_FILENAME,
2627
RESULT_COLUMN_NAME,
@@ -72,6 +73,16 @@
7273
"--unset-default-target",
7374
mutually_exclusive=["default_target"],
7475
)
76+
DefaultEnvironmentOption = OverrideableOption(
77+
None,
78+
"--default-environment",
79+
mutually_exclusive=["unset_default_environment"],
80+
)
81+
UnsetDefaultEnvironmentOption = OverrideableOption(
82+
False,
83+
"--unset-default-environment",
84+
mutually_exclusive=["default_environment"],
85+
)
7586

7687
add_object_command_aliases(
7788
app=app,
@@ -89,6 +100,11 @@ def _env_callback(value: Optional[str]) -> Optional[str]:
89100
return _reject_control_chars(value, "--env")
90101

91102

103+
def _default_environment_callback(value: Optional[str]) -> Optional[str]:
104+
return _reject_control_chars(value, "--default-environment")
105+
106+
107+
92108
@app.command(
93109
"deploy",
94110
requires_connection=True,
@@ -105,6 +121,16 @@ def deploy_dbt(
105121
show_default=False,
106122
default=None,
107123
),
124+
env_file_dir: Optional[str] = typer.Option(
125+
help=(
126+
f"Path to directory containing {ENV_FILENAME}. If provided, the file is "
127+
f"injected into the deployed project root, overwriting any {ENV_FILENAME} "
128+
f"present in --source."
129+
),
130+
show_default=False,
131+
default=None,
132+
hidden=not FeatureFlag.ENABLE_DBT_PROJECT_ENV_VARS.is_enabled(),
133+
),
108134
force: Optional[bool] = typer.Option(
109135
False,
110136
help="Overwrites conflicting files in the project, if any.",
@@ -115,6 +141,20 @@ def deploy_dbt(
115141
unset_default_target: Optional[bool] = UnsetDefaultTargetOption(
116142
help="Unset the default target for the dbt project. Mutually exclusive with --default-target.",
117143
),
144+
default_environment: Optional[str] = DefaultEnvironmentOption(
145+
help=(
146+
f"Default environment for the dbt project (DEFAULT_ENVIRONMENT). "
147+
f"Selects the environment block from {ENV_FILENAME} that the project "
148+
f"compiles and executes with by default. "
149+
f"Mutually exclusive with --unset-default-environment."
150+
),
151+
callback=_default_environment_callback,
152+
hidden=not FeatureFlag.ENABLE_DBT_PROJECT_ENV_VARS.is_enabled(),
153+
),
154+
unset_default_environment: Optional[bool] = UnsetDefaultEnvironmentOption(
155+
help="Unset the default environment for the dbt project. Mutually exclusive with --default-environment.",
156+
hidden=not FeatureFlag.ENABLE_DBT_PROJECT_ENV_VARS.is_enabled(),
157+
),
118158
external_access_integrations: Optional[list[str]] = typer.Option(
119159
None,
120160
"--external-access-integration",
@@ -144,9 +184,12 @@ def deploy_dbt(
144184
"""
145185
project_path = SecurePath(source) if source is not None else SecurePath.cwd()
146186
profiles_dir_path = SecurePath(profiles_dir) if profiles_dir else project_path
187+
env_file_path = SecurePath(env_file_dir) if env_file_dir else None
147188
attrs = DBTDeployAttributes(
148189
default_target=default_target,
149190
unset_default_target=unset_default_target,
191+
default_environment=default_environment,
192+
unset_default_environment=unset_default_environment,
150193
external_access_integrations=external_access_integrations,
151194
install_local_deps=install_local_deps,
152195
dbt_version=dbt_version,
@@ -156,6 +199,7 @@ def deploy_dbt(
156199
name,
157200
path=project_path.resolve(),
158201
profiles_path=profiles_dir_path.resolve(),
202+
env_file_path=env_file_path.resolve() if env_file_path else None,
159203
force=force,
160204
attrs=attrs,
161205
)

src/snowflake/cli/_plugins/dbt/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
OUTPUT_COLUMN_NAME = "STDOUT"
1717
PROFILES_FILENAME = "profiles.yml"
1818
SUPPORTED_DBT_VERSIONS_QUERY = "SELECT SYSTEM$SUPPORTED_DBT_VERSIONS()"
19+
ENV_FILENAME = "env.yml"
1920

2021
DBT_COMMANDS = [
2122
"build",

src/snowflake/cli/_plugins/dbt/manager.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
import yaml
2626
from snowflake.cli._plugins.dbt.constants import (
27+
ENV_FILENAME,
2728
PROFILES_FILENAME,
2829
SUPPORTED_DBT_VERSIONS_QUERY,
2930
)
@@ -86,6 +87,7 @@ def _no_duplicates_constructor(loader, node, deep=False):
8687

8788
class DBTObjectEditableAttributes(TypedDict):
8889
default_target: Optional[str]
90+
default_environment: Optional[str]
8991
external_access_integrations: Optional[List[str]]
9092
dbt_version: Optional[str]
9193

@@ -96,6 +98,8 @@ class DBTDeployAttributes:
9698

9799
default_target: Optional[str] = None
98100
unset_default_target: bool = False
101+
default_environment: Optional[str] = None
102+
unset_default_environment: bool = False
99103
external_access_integrations: Optional[List[str]] = None
100104
install_local_deps: bool = False
101105
dbt_version: Optional[str] = None
@@ -152,6 +156,7 @@ def get_dbt_object_attributes(name: FQN) -> Optional[DBTObjectEditableAttributes
152156

153157
return DBTObjectEditableAttributes(
154158
default_target=row_dict.get("default_target"),
159+
default_environment=row_dict.get("default_environment"),
155160
external_access_integrations=external_access_integrations,
156161
dbt_version=row_dict.get("dbt_version"),
157162
)
@@ -197,6 +202,7 @@ def deploy(
197202
profiles_path: SecurePath,
198203
force: bool,
199204
attrs: DBTDeployAttributes,
205+
env_file_path: Optional[SecurePath] = None,
200206
) -> SnowflakeCursor:
201207
dbt_project_path = path / "dbt_project.yml"
202208
if not dbt_project_path.exists():
@@ -216,6 +222,13 @@ def deploy(
216222
if attrs.dbt_version:
217223
self._validate_dbt_version(attrs.dbt_version)
218224

225+
if env_file_path is not None:
226+
env_file = env_file_path / ENV_FILENAME
227+
if not env_file.exists():
228+
raise CliError(
229+
f"{ENV_FILENAME} does not exist in directory {env_file_path.path.absolute()}."
230+
)
231+
219232
with cli_console.phase("Creating temporary stage"):
220233
stage_manager = StageManager()
221234
stage_fqn = FQN.from_resource(ObjectType.DBT_PROJECT, fqn, "STAGE")
@@ -227,6 +240,8 @@ def deploy(
227240
tmp_path = Path(tmp)
228241
stage_manager.copy_to_tmp_dir(path.path, tmp_path)
229242
self._prepare_profiles_file(profiles_path.path, tmp_path)
243+
if env_file_path is not None:
244+
self._prepare_env_file(env_file_path.path, tmp_path)
230245
result_count = len(
231246
list(
232247
stage_manager.put_recursive(
@@ -267,6 +282,15 @@ def _deploy_alter(
267282
):
268283
set_properties.append(f"DEFAULT_TARGET='{attrs.default_target}'")
269284

285+
# Always issue SET/UNSET when the user asks; the server treats
286+
# UNSET-on-null as a no-op.
287+
if attrs.unset_default_environment:
288+
unset_properties.append("DEFAULT_ENVIRONMENT")
289+
elif attrs.default_environment:
290+
set_properties.append(
291+
f"DEFAULT_ENVIRONMENT={to_string_literal(attrs.default_environment)}"
292+
)
293+
270294
# Comparing dbt_version to existing project's dbt_version might be ambiguous
271295
# if previously project was locked to just minor version and now user wants to
272296
# lock it to a patch as well. If target version is provided, it's better to just
@@ -335,6 +359,10 @@ def _deploy_create(
335359
query += f"\nFROM {stage_name}"
336360
if attrs.default_target:
337361
query += f" DEFAULT_TARGET='{attrs.default_target}'"
362+
if attrs.default_environment:
363+
query += (
364+
f" DEFAULT_ENVIRONMENT={to_string_literal(attrs.default_environment)}"
365+
)
338366
if attrs.dbt_version:
339367
query += f" DBT_VERSION={to_string_literal(attrs.dbt_version)}"
340368
query = self._handle_external_access_integrations_query(
@@ -366,6 +394,10 @@ def _deploy_create_or_replace(
366394
query += f"\nFROM {stage_name}"
367395
if attrs.default_target:
368396
query += f" DEFAULT_TARGET='{attrs.default_target}'"
397+
if attrs.default_environment:
398+
query += (
399+
f" DEFAULT_ENVIRONMENT={to_string_literal(attrs.default_environment)}"
400+
)
369401
if attrs.dbt_version:
370402
query += f" DBT_VERSION={to_string_literal(attrs.dbt_version)}"
371403
query = self._handle_external_access_integrations_query(
@@ -462,6 +494,17 @@ def _prepare_profiles_file(profiles_path: Path, tmp_path: Path):
462494
) as sfd, target_profiles_file.open(mode="w") as tfd:
463495
yaml.safe_dump(yaml.safe_load(sfd), tfd)
464496

497+
@staticmethod
498+
def _prepare_env_file(env_path: Path, tmp_path: Path):
499+
source_env_file = SecurePath(env_path / ENV_FILENAME)
500+
target_env_file = SecurePath(tmp_path / ENV_FILENAME)
501+
if target_env_file.exists():
502+
target_env_file.unlink()
503+
with source_env_file.open(
504+
read_file_limit_mb=DEFAULT_SIZE_LIMIT_MB
505+
) as sfd, target_env_file.open(mode="w") as tfd:
506+
yaml.safe_dump(yaml.safe_load(sfd), tfd)
507+
465508
def execute(
466509
self,
467510
dbt_command: str,

tests/dbt/conftest.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44
import yaml
5-
from snowflake.cli._plugins.dbt.constants import PROFILES_FILENAME
5+
from snowflake.cli._plugins.dbt.constants import ENV_FILENAME, PROFILES_FILENAME
66

77

88
@pytest.fixture
@@ -40,6 +40,19 @@ def profile():
4040
return profiles
4141

4242

43+
@pytest.fixture()
44+
def env_yml():
45+
return {
46+
"env_config": {
47+
"default_environment": "dev",
48+
"environments": [
49+
{"name": "dev", "env": {"DBT_FOO": "bar"}},
50+
{"name": "prod", "env": {"DBT_FOO": "baz"}},
51+
],
52+
}
53+
}
54+
55+
4356
@pytest.fixture
4457
def project_path(tmp_path_factory):
4558
source_path = tmp_path_factory.mktemp("dbt_project")
@@ -55,6 +68,13 @@ def dbt_project_path(project_path, profile):
5568
yield project_path
5669

5770

71+
@pytest.fixture
72+
def env_yml_dir(tmp_path_factory, env_yml):
73+
env_path = tmp_path_factory.mktemp("dbt_envs")
74+
(env_path / ENV_FILENAME).write_text(yaml.dump(env_yml))
75+
yield env_path
76+
77+
5878
@pytest.fixture
5979
def mock_get_dbt_object_attributes():
6080
with mock.patch(

tests/dbt/test_dbt_commands.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,102 @@ def test_dbt_deploy_with_custom_profiles_dir(
194194
assert str(mock_deploy.call_args[0][0]) == "TEST_PIPELINE"
195195
assert call_kwargs["profiles_path"] == SecurePath(new_profiles_directory)
196196

197+
def test_dbt_deploy_with_env_file_dir(
198+
self, runner, dbt_project_path, env_yml_dir, mock_deploy
199+
):
200+
result = runner.invoke(
201+
[
202+
"dbt",
203+
"deploy",
204+
"TEST_PIPELINE",
205+
f"--source={dbt_project_path}",
206+
f"--env-file-dir={env_yml_dir}",
207+
]
208+
)
209+
210+
assert result.exit_code == 0, result.output
211+
mock_deploy.assert_called_once()
212+
call_kwargs = mock_deploy.call_args[1]
213+
assert call_kwargs["env_file_path"] == SecurePath(env_yml_dir)
214+
215+
def test_dbt_deploy_without_env_file_dir_passes_none(
216+
self, runner, dbt_project_path, mock_deploy
217+
):
218+
result = runner.invoke(
219+
[
220+
"dbt",
221+
"deploy",
222+
"TEST_PIPELINE",
223+
f"--source={dbt_project_path}",
224+
]
225+
)
226+
227+
assert result.exit_code == 0, result.output
228+
mock_deploy.assert_called_once()
229+
call_kwargs = mock_deploy.call_args[1]
230+
assert call_kwargs["env_file_path"] is None
231+
232+
def test_deploy_with_default_environment_passes_to_manager(
233+
self, runner, dbt_project_path, mock_deploy
234+
):
235+
result = runner.invoke(
236+
[
237+
"dbt",
238+
"deploy",
239+
"TEST_PIPELINE",
240+
f"--source={dbt_project_path}",
241+
"--default-environment=dev",
242+
]
243+
)
244+
245+
assert result.exit_code == 0, result.output
246+
mock_deploy.assert_called_once()
247+
call_kwargs = mock_deploy.call_args[1]
248+
assert call_kwargs["attrs"].default_environment == "dev"
249+
assert call_kwargs["attrs"].unset_default_environment is False
250+
251+
def test_deploy_with_unset_default_environment_passes_to_manager(
252+
self, runner, dbt_project_path, mock_deploy
253+
):
254+
result = runner.invoke(
255+
[
256+
"dbt",
257+
"deploy",
258+
"TEST_PIPELINE",
259+
f"--source={dbt_project_path}",
260+
"--unset-default-environment",
261+
]
262+
)
263+
264+
assert result.exit_code == 0, result.output
265+
mock_deploy.assert_called_once()
266+
call_kwargs = mock_deploy.call_args[1]
267+
assert call_kwargs["attrs"].default_environment is None
268+
assert call_kwargs["attrs"].unset_default_environment is True
269+
270+
def test_deploy_with_both_default_environment_and_unset_default_environment_fails(
271+
self,
272+
mock_connect,
273+
runner,
274+
dbt_project_path,
275+
):
276+
result = runner.invoke(
277+
[
278+
"dbt",
279+
"deploy",
280+
"TEST_PIPELINE",
281+
f"--source={dbt_project_path}",
282+
"--default-environment=dev",
283+
"--unset-default-environment",
284+
]
285+
)
286+
287+
assert result.exit_code == 2, result.output
288+
# Box-rendered error wraps across lines; check the key parts.
289+
assert "'--unset-default-environment'" in result.output
290+
assert "'--default-environment'" in result.output
291+
assert "incompatible" in result.output
292+
197293
def test_deploy_with_default_target_passes_to_manager(
198294
self, runner, dbt_project_path, mock_deploy
199295
):

0 commit comments

Comments
 (0)