Skip to content

Commit 8cdccef

Browse files
feat: [SNOW-2306301] extend dcm plan/deploy --from flag to support local paths (#2596)
1 parent 2574b13 commit 8cdccef

File tree

7 files changed

+250
-28
lines changed

7 files changed

+250
-28
lines changed

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

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
QueryJsonValueResult,
4040
QueryResult,
4141
)
42+
from snowflake.cli.api.utils.path_utils import is_stage_path
4243

4344
app = SnowTyperFactory(
4445
name="dcm",
@@ -56,9 +57,10 @@
5657
help="Configuration of the DCM Project to use. If not specified default configuration is used.",
5758
show_default=False,
5859
)
59-
from_option = OverrideableOption(
60+
from_option = typer.Option(
6061
None,
6162
"--from",
63+
help="Source location: stage path (starting with '@') or local directory path. Omit to use current directory.",
6264
show_default=False,
6365
)
6466

@@ -106,9 +108,7 @@
106108
@app.command(requires_connection=True)
107109
def deploy(
108110
identifier: FQN = dcm_identifier,
109-
from_stage: Optional[str] = from_option(
110-
help="Deploy DCM Project deployment from a given stage."
111-
),
111+
from_location: Optional[str] = from_option,
112112
variables: Optional[List[str]] = variables_flag,
113113
configuration: Optional[str] = configuration_flag,
114114
alias: Optional[str] = alias_option,
@@ -118,14 +118,14 @@ def deploy(
118118
Applies changes defined in DCM Project to Snowflake.
119119
"""
120120
manager = DCMProjectManager()
121-
if not from_stage:
122-
from_stage = manager.sync_local_files(project_identifier=identifier)
121+
effective_stage = _get_effective_stage(identifier, from_location)
122+
123123
with cli_console.spinner() as spinner:
124124
spinner.add_task(description=f"Deploying dcm project {identifier}", total=None)
125125
result = manager.execute(
126126
project_identifier=identifier,
127127
configuration=configuration,
128-
from_stage=from_stage,
128+
from_stage=effective_stage,
129129
variables=variables,
130130
alias=alias,
131131
output_path=None,
@@ -136,9 +136,7 @@ def deploy(
136136
@app.command(requires_connection=True)
137137
def plan(
138138
identifier: FQN = dcm_identifier,
139-
from_stage: Optional[str] = from_option(
140-
help="Plan DCM Project deployment from a given stage."
141-
),
139+
from_location: Optional[str] = from_option,
142140
variables: Optional[List[str]] = variables_flag,
143141
configuration: Optional[str] = configuration_flag,
144142
output_path: Optional[str] = output_path_option(
@@ -150,15 +148,14 @@ def plan(
150148
Plans a DCM Project deployment (validates without executing).
151149
"""
152150
manager = DCMProjectManager()
153-
if not from_stage:
154-
from_stage = manager.sync_local_files(project_identifier=identifier)
151+
effective_stage = _get_effective_stage(identifier, from_location)
155152

156153
with cli_console.spinner() as spinner:
157154
spinner.add_task(description=f"Planning dcm project {identifier}", total=None)
158155
result = manager.execute(
159156
project_identifier=identifier,
160157
configuration=configuration,
161-
from_stage=from_stage,
158+
from_stage=effective_stage,
162159
dry_run=True,
163160
variables=variables,
164161
output_path=output_path,
@@ -236,3 +233,16 @@ def drop_deployment(
236233
return MessageResult(
237234
f"Deployment '{deployment_name}' dropped from DCM Project '{identifier}'."
238235
)
236+
237+
238+
def _get_effective_stage(identifier: FQN, from_location: Optional[str]):
239+
manager = DCMProjectManager()
240+
if not from_location:
241+
from_stage = manager.sync_local_files(project_identifier=identifier)
242+
elif is_stage_path(from_location):
243+
from_stage = from_location
244+
else:
245+
from_stage = manager.sync_local_files(
246+
project_identifier=identifier, source_directory=from_location
247+
)
248+
return from_stage

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,20 @@ def drop_deployment(
137137
return self.execute_query(query=query)
138138

139139
@staticmethod
140-
def sync_local_files(project_identifier: FQN) -> str:
141-
dcm_manifest_file = SecurePath.cwd() / MANIFEST_FILE_NAME
140+
def sync_local_files(
141+
project_identifier: FQN, source_directory: str | None = None
142+
) -> str:
143+
source_path = (
144+
SecurePath(source_directory).resolve()
145+
if source_directory
146+
else SecurePath.cwd()
147+
)
148+
149+
dcm_manifest_file = source_path / MANIFEST_FILE_NAME
142150
if not dcm_manifest_file.exists():
143-
raise CliError(f"{MANIFEST_FILE_NAME} was not found in project directory")
151+
raise CliError(
152+
f"{MANIFEST_FILE_NAME} was not found in directory {source_path.path}"
153+
)
144154

145155
with dcm_manifest_file.open(read_file_limit_mb=DEFAULT_SIZE_LIMIT_MB) as fd:
146156
dcm_manifest = yaml.safe_load(fd)
@@ -160,10 +170,10 @@ def sync_local_files(project_identifier: FQN) -> str:
160170

161171
with cli_console.phase(f"Uploading definition files"):
162172
stage_fqn = FQN.from_resource(
163-
ObjectType.DCM_PROJECT, project_identifier, "_TMP_STAGE"
173+
ObjectType.DCM_PROJECT, project_identifier, "TMP_STAGE"
164174
)
165175
sync_artifacts_with_stage(
166-
project_paths=ProjectPaths(project_root=Path.cwd()),
176+
project_paths=ProjectPaths(project_root=source_path.path),
167177
stage_root=stage_fqn.identifier,
168178
use_temporary_stage=True,
169179
artifacts=[PathMapping(src=definition) for definition in definitions],

tests/__snapshots__/test_help_messages.ambr

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7388,8 +7388,9 @@
73887388
| [required] |
73897389
+------------------------------------------------------------------------------+
73907390
+- Options --------------------------------------------------------------------+
7391-
| --from TEXT Deploy DCM Project deployment from a given |
7392-
| stage. |
7391+
| --from TEXT Source location: stage path (starting with |
7392+
| '@') or local directory path. Omit to use |
7393+
| current directory. |
73937394
| --variable -D TEXT Variables for the execution context; for |
73947395
| example: -D "<key>=<value>". |
73957396
| --configuration TEXT Configuration of the DCM Project to use. If |
@@ -8204,8 +8205,9 @@
82048205
| [required] |
82058206
+------------------------------------------------------------------------------+
82068207
+- Options --------------------------------------------------------------------+
8207-
| --from TEXT Plan DCM Project deployment from a given |
8208-
| stage. |
8208+
| --from TEXT Source location: stage path (starting with |
8209+
| '@') or local directory path. Omit to use |
8210+
| current directory. |
82098211
| --variable -D TEXT Variables for the execution context; for |
82108212
| example: -D "<key>=<value>". |
82118213
| --configuration TEXT Configuration of the DCM Project to use. If |

tests/api/test_fqn.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def test_git_fqn():
200200
assert fqn.name == "git_repo"
201201

202202

203-
class TestRelatedToResource:
203+
class TestFromResource:
204204
@pytest.fixture
205205
def mock_time(self):
206206
with mock.patch(

tests/dcm/test_commands.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,43 @@ def test_deploy_project_with_sync(
215215
assert "DCM_FOOBAR" in call_args.kwargs["from_stage"]
216216
assert call_args.kwargs["from_stage"].endswith("_TMP_STAGE")
217217

218+
@mock.patch(DCMProjectManager)
219+
def test_deploy_project_with_from_local_directory(
220+
self,
221+
mock_pm,
222+
runner,
223+
project_directory,
224+
mock_cursor,
225+
mock_connect,
226+
tmp_path,
227+
):
228+
mock_pm().execute.return_value = mock_cursor(
229+
rows=[("[]",)], columns=("operations")
230+
)
231+
mock_pm().sync_local_files.return_value = (
232+
"MockDatabase.MockSchema.DCM_FOOBAR_1234567890_TMP_STAGE"
233+
)
234+
235+
source_dir = tmp_path / "source_project"
236+
source_dir.mkdir()
237+
238+
manifest_file = source_dir / "manifest.yml"
239+
manifest_file.write_text("type: dcm_project\n")
240+
241+
with project_directory("dcm_project"):
242+
result = runner.invoke(
243+
["dcm", "deploy", "my_project", "--from", str(source_dir)]
244+
)
245+
assert result.exit_code == 0, result.output
246+
247+
mock_pm().sync_local_files.assert_called_once_with(
248+
project_identifier=FQN.from_string("my_project"),
249+
source_directory=str(source_dir),
250+
)
251+
252+
call_args = mock_pm().execute.call_args
253+
assert call_args.kwargs["from_stage"].endswith("_TMP_STAGE")
254+
218255

219256
class TestDCMPlan:
220257
@mock.patch(DCMProjectManager)
@@ -376,6 +413,42 @@ def test_plan_project_with_sync(
376413
assert "DCM_FOOBAR_" in call_args.kwargs["from_stage"]
377414
assert call_args.kwargs["from_stage"].endswith("_TMP_STAGE")
378415

416+
@mock.patch(DCMProjectManager)
417+
def test_plan_project_with_from_local_directory(
418+
self,
419+
mock_pm,
420+
runner,
421+
project_directory,
422+
mock_cursor,
423+
mock_connect,
424+
tmp_path,
425+
):
426+
mock_pm().execute.return_value = mock_cursor(
427+
rows=[("[]",)], columns=("operations")
428+
)
429+
mock_pm().sync_local_files.return_value = (
430+
"MockDatabase.MockSchema.DCM_FOOBAR_1234567890_TMP_STAGE"
431+
)
432+
433+
source_dir = tmp_path / "source_project"
434+
source_dir.mkdir()
435+
manifest_file = source_dir / "manifest.yml"
436+
manifest_file.write_text("type: dcm_project\n")
437+
438+
with project_directory("dcm_project"):
439+
result = runner.invoke(
440+
["dcm", "plan", "my_project", "--from", str(source_dir)]
441+
)
442+
assert result.exit_code == 0, result.output
443+
444+
mock_pm().sync_local_files.assert_called_once_with(
445+
project_identifier=FQN.from_string("my_project"),
446+
source_directory=str(source_dir),
447+
)
448+
449+
call_args = mock_pm().execute.call_args
450+
assert call_args.kwargs["from_stage"].endswith("_TMP_STAGE")
451+
379452

380453
class TestDCMList:
381454
def test_list_command_alias(self, mock_connect, runner):

tests/dcm/test_manager.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
from pathlib import Path
23
from unittest import mock
34

@@ -251,7 +252,7 @@ def test_raises_when_manifest_file_is_missing(self, project_directory):
251252
(project_dir / MANIFEST_FILE_NAME).unlink()
252253
with pytest.raises(
253254
CliError,
254-
match=f"{MANIFEST_FILE_NAME} was not found in project directory",
255+
match=f"{MANIFEST_FILE_NAME} was not found in directory",
255256
):
256257
DCMProjectManager.sync_local_files(project_identifier=TEST_PROJECT)
257258

@@ -314,3 +315,75 @@ def test_calls_sync_artifacts_with_stage(
314315
actual_project_root = call_args.kwargs["project_paths"].project_root
315316
expected_project_root = project_dir.resolve()
316317
assert actual_project_root.resolve() == expected_project_root.resolve()
318+
319+
@mock.patch("snowflake.cli._plugins.dcm.manager.sync_artifacts_with_stage")
320+
@mock.patch("snowflake.cli._plugins.dcm.manager.StageManager.create")
321+
def test_sync_local_files_with_source_directory(
322+
self,
323+
_mock_create_stage,
324+
mock_sync_artifacts_with_stage,
325+
tmp_path,
326+
mock_connect,
327+
mock_cursor,
328+
mock_from_resource,
329+
):
330+
source_dir = tmp_path / "custom_source"
331+
source_dir.mkdir()
332+
333+
manifest_content = {
334+
"type": "dcm_project",
335+
"include_definitions": ["definitions/custom_query.sql"],
336+
}
337+
manifest_file = source_dir / MANIFEST_FILE_NAME
338+
with open(manifest_file, "w") as f:
339+
yaml.dump(manifest_content, f)
340+
341+
# Create the definition file
342+
definitions_dir = source_dir / "definitions"
343+
definitions_dir.mkdir()
344+
(definitions_dir / "custom_query.sql").write_text("SELECT 1;")
345+
346+
DCMProjectManager.sync_local_files(
347+
project_identifier=TEST_PROJECT, source_directory=str(source_dir)
348+
)
349+
350+
mock_sync_artifacts_with_stage.assert_called_once()
351+
call_args = mock_sync_artifacts_with_stage.call_args
352+
actual_project_root = call_args.kwargs["project_paths"].project_root
353+
assert actual_project_root.resolve() == source_dir.resolve()
354+
355+
@mock.patch("snowflake.cli._plugins.dcm.manager.sync_artifacts_with_stage")
356+
@mock.patch("snowflake.cli._plugins.dcm.manager.StageManager.create")
357+
def test_sync_local_files_with_relative_source_directory(
358+
self,
359+
_mock_create_stage,
360+
mock_sync_artifacts_with_stage,
361+
tmp_path,
362+
mock_connect,
363+
mock_cursor,
364+
mock_from_resource,
365+
):
366+
source_dir = tmp_path / "relative_source"
367+
source_dir.mkdir()
368+
369+
manifest_file = source_dir / MANIFEST_FILE_NAME
370+
with open(manifest_file, "w") as f:
371+
yaml.dump({"type": "dcm_project"}, f)
372+
373+
original_cwd = os.getcwd()
374+
try:
375+
os.chdir(tmp_path)
376+
377+
DCMProjectManager.sync_local_files(
378+
project_identifier=TEST_PROJECT,
379+
source_directory="relative_source", # relative path
380+
)
381+
382+
mock_sync_artifacts_with_stage.assert_called_once()
383+
call_args = mock_sync_artifacts_with_stage.call_args
384+
385+
actual_project_root = call_args.kwargs["project_paths"].project_root
386+
assert actual_project_root.is_absolute()
387+
assert actual_project_root.resolve() == source_dir.resolve()
388+
finally:
389+
os.chdir(original_cwd)

0 commit comments

Comments
 (0)