diff --git a/src/snowflake/cli/_plugins/dcm/commands.py b/src/snowflake/cli/_plugins/dcm/commands.py index 84eb57dbad..b6551df020 100644 --- a/src/snowflake/cli/_plugins/dcm/commands.py +++ b/src/snowflake/cli/_plugins/dcm/commands.py @@ -23,6 +23,7 @@ ) from snowflake.cli._plugins.dcm.manager import DCMProjectManager from snowflake.cli._plugins.dcm.models import DCMManifest, DCMTarget, TargetContext +from snowflake.cli._plugins.dcm.progress import DeployProgressTracker from snowflake.cli._plugins.dcm.reporters import ( AnalyzeReporter, PlanReporter, @@ -336,17 +337,18 @@ def deploy( context = _resolve_context_with_required_manifest(from_location, identifier, target) project_id = context.project_identifier - manager = DCMProjectManager() - effective_stage = manager.sync_local_files( - project_identifier=project_id, - source_directory=str(from_location.path), - ) + if skip_plan: + cli_console.warning("Skipping planning step") - with cli_console.spinner() as spinner: - spinner.add_task(description=f"Deploying dcm project {project_id}", total=None) - if skip_plan: - cli_console.warning("Skipping planning step") - result = manager.deploy( + manager = DCMProjectManager() + tracker = DeployProgressTracker(conn=manager.connection) + with tracker.session(): + effective_stage = manager.sync_local_files( + project_identifier=project_id, + source_directory=str(from_location.path), + progress=tracker, + ) + sfqid = manager.deploy_async( project_identifier=project_id, configuration=context.configuration, from_stage=effective_stage, @@ -354,6 +356,7 @@ def deploy( alias=alias, skip_plan=skip_plan, ) + result = tracker.run_deploy_poll(sfqid) reporter = PlanReporter(save_output=save_output, command_name="deploy") return reporter.process(result) @@ -422,15 +425,18 @@ def purge( if not force: _confirm_purge(project_id) - with cli_console.spinner() as spinner: - spinner.add_task(description=f"Purging dcm project {project_id}", total=None) - if skip_plan: - cli_console.warning("Skipping planning step") - result = DCMProjectManager().purge( + if skip_plan: + cli_console.warning("Skipping planning step") + + manager = DCMProjectManager() + tracker = DeployProgressTracker(conn=manager.connection, operation="purge") + with tracker.session(): + sfqid = manager.purge_async( project_identifier=project_id, alias=alias, skip_plan=skip_plan, ) + result = tracker.run_deploy_poll(sfqid) reporter = PlanReporter(save_output=save_output, command_name="purge") return reporter.process(result) @@ -461,20 +467,24 @@ def plan( project_id = context.project_identifier manager = DCMProjectManager() - effective_stage = manager.sync_local_files( - project_identifier=project_id, - source_directory=str(from_location.path), - ) - - with cli_console.spinner() as spinner: - spinner.add_task(description=f"Planning dcm project {project_id}", total=None) - result = manager.plan( + tracker = DeployProgressTracker(conn=manager.connection, operation="plan") + with tracker.session(): + effective_stage = manager.sync_local_files( project_identifier=project_id, - configuration=context.configuration, - from_stage=effective_stage, - variables=variables, - save_output=save_output, - delta=delta, + source_directory=str(from_location.path), + progress=tracker, + ) + result = tracker.run_loader_phase( + lambda: manager.plan( + project_identifier=project_id, + configuration=context.configuration, + from_stage=effective_stage, + variables=variables, + save_output=save_output, + delta=delta, + ), + phase_name="PLAN", + simulated_phases=("RENDER", "COMPILE"), ) reporter = PlanReporter(save_output=save_output, command_name="plan") diff --git a/src/snowflake/cli/_plugins/dcm/manager.py b/src/snowflake/cli/_plugins/dcm/manager.py index 8b777b3028..f8eb94864b 100644 --- a/src/snowflake/cli/_plugins/dcm/manager.py +++ b/src/snowflake/cli/_plugins/dcm/manager.py @@ -59,6 +59,24 @@ def connection(self): """Exposes the underlying Snowflake connection.""" return self._conn + def _build_deploy_query( + self, + project_identifier: FQN, + from_stage: str, + configuration: str | None, + variables: List[str] | None, + alias: str | None, + skip_plan: bool, + ) -> str: + query = f"EXECUTE DCM PROJECT {project_identifier.sql_identifier} DEPLOY" + if alias: + query += f' AS "{alias}"' + query += self._get_configuration_and_variables_query(configuration, variables) + query += self._get_from_stage_query(from_stage) + if skip_plan: + query += " SKIP PLAN" + return query + def deploy( self, project_identifier: FQN, @@ -75,15 +93,48 @@ def deploy( len(variables or []), skip_plan, ) - query = f"EXECUTE DCM PROJECT {project_identifier.sql_identifier} DEPLOY" - if alias: - query += f' AS "{alias}"' - query += self._get_configuration_and_variables_query(configuration, variables) - query += self._get_from_stage_query(from_stage) - if skip_plan: - query += f" SKIP PLAN" + query = self._build_deploy_query( + project_identifier, from_stage, configuration, variables, alias, skip_plan + ) return self.execute_query(query=query) + def deploy_async( + self, + project_identifier: FQN, + from_stage: str, + configuration: str | None = None, + variables: List[str] | None = None, + alias: str | None = None, + skip_plan: bool = False, + ) -> str: + """ + Submits a deploy query asynchronously and returns the Snowflake query ID (sfqid). + Use with :class:`~snowflake.cli._plugins.dcm.progress.DeployProgressTracker` + to poll progress and obtain the final result cursor. + """ + log.info( + "Submitting DCM deploy async (project_identifier=%s, has_configuration=%s, variables_count=%d, skip_plan=%s).", + project_identifier, + bool(configuration), + len(variables or []), + skip_plan, + ) + query = self._build_deploy_query( + project_identifier, from_stage, configuration, variables, alias, skip_plan + ) + # Closing the cursor does not cancel the async query; it keeps running + # server-side and its results are fetched later via the sfqid (see + # DeployProgressTracker.run_deploy_poll -> get_results_from_sfqid). + with self._conn.cursor() as cursor: + cursor.execute_async(query) + sfqid = cursor.sfqid + log.info( + "DCM deploy async submitted (project_identifier=%s, sfqid=%s).", + project_identifier, + sfqid, + ) + return sfqid + def raw_analyze( self, project_identifier: FQN, @@ -223,12 +274,50 @@ def purge( project_identifier, skip_plan, ) + query = self._build_purge_query(project_identifier, alias, skip_plan) + return self.execute_query(query=query) + + def purge_async( + self, + project_identifier: FQN, + alias: str | None = None, + skip_plan: bool = False, + ) -> str: + """ + Submits a purge query asynchronously and returns the Snowflake query ID (sfqid). + Use with :class:`~snowflake.cli._plugins.dcm.progress.DeployProgressTracker` + to poll progress and obtain the final result cursor. + """ + log.info( + "Submitting DCM purge async (project_identifier=%s, skip_plan=%s).", + project_identifier, + skip_plan, + ) + query = self._build_purge_query(project_identifier, alias, skip_plan) + # Closing the cursor does not cancel the async query; results are + # fetched later via the sfqid (see run_deploy_poll). + with self._conn.cursor() as cursor: + cursor.execute_async(query) + sfqid = cursor.sfqid + log.info( + "DCM purge async submitted (project_identifier=%s, sfqid=%s).", + project_identifier, + sfqid, + ) + return sfqid + + @staticmethod + def _build_purge_query( + project_identifier: FQN, + alias: str | None, + skip_plan: bool, + ) -> str: query = f"EXECUTE DCM PROJECT {project_identifier.sql_identifier} PURGE" if alias: query += f' AS "{alias}"' if skip_plan: query += " SKIP PLAN" - return self.execute_query(query=query) + return query def test(self, project_identifier: FQN) -> SnowflakeCursor: log.info( diff --git a/src/snowflake/cli/_plugins/dcm/progress.py b/src/snowflake/cli/_plugins/dcm/progress.py index c43afbf23c..3dfeaaffcd 100644 --- a/src/snowflake/cli/_plugins/dcm/progress.py +++ b/src/snowflake/cli/_plugins/dcm/progress.py @@ -457,14 +457,14 @@ def _append_progress_bar(self, out: Text, progress: int) -> None: in_progress = 0 < filled < _BAR_WIDTH if in_progress: - out.append(_BAR_CELL * filled + _BAR_LEADING_EDGE, style="blue") + out.append(_BAR_CELL * filled + _BAR_LEADING_EDGE, style=styles.BLUE) out.append(_BAR_CELL * (_BAR_WIDTH - filled - 1), style="dim") elif filled == 0: out.append(_BAR_CELL * _BAR_WIDTH, style="dim") else: - out.append(_BAR_CELL * _BAR_WIDTH, style="blue") + out.append(_BAR_CELL * _BAR_WIDTH, style=styles.BLUE) - out.append(f" {progress:>3}%", style="blue") + out.append(f" {progress:>3}%", style=styles.BLUE) def _append_upload_details(self, out: Text) -> None: """Render the stage-creation message and folder counters indented @@ -509,7 +509,7 @@ def _render_phase_line(self, out: Text, phase: _Phase) -> None: # Blue matches the active-indicator color used by the # pip-style progress bar so all "this phase is in flight" # signals share the same hue. - out.append(_spinner_glyph(), style="blue") + out.append(_spinner_glyph(), style=styles.BLUE) out.append(duration_str + "\n", style="dim") else: # PENDING out.append(name_col + "·\n", style="dim") diff --git a/src/snowflake/cli/_plugins/dcm/styles.py b/src/snowflake/cli/_plugins/dcm/styles.py index eec48d3e37..506d5dd99e 100644 --- a/src/snowflake/cli/_plugins/dcm/styles.py +++ b/src/snowflake/cli/_plugins/dcm/styles.py @@ -13,13 +13,17 @@ # limitations under the License. from rich.style import Style -_COLOR_BLUE = "#a0a8fe" +# Single source of truth for the DCM "blue" hue. Use the terminal-default +# named color (not a fixed hex) so it respects the user's theme and stays +# identical everywhere it's used: progress spinner/bar, running phase, +# refresh status, and unknown rows. +BLUE = "blue" DOMAIN_STYLE = Style(color="cyan") BOLD_STYLE = Style(bold=True) # Refresh -STATUS_STYLE = Style(color=_COLOR_BLUE) +STATUS_STYLE = Style(color=BLUE) REMOVED_STYLE = Style(color="red", italic=True) INSERTED_STYLE = Style(color="green", italic=True) @@ -31,9 +35,9 @@ CREATE_STYLE = Style(color="green") ALTER_STYLE = Style(color="yellow") DROP_STYLE = Style(color="red") -UNKNOWN_STYLE = Style(color=_COLOR_BLUE) +UNKNOWN_STYLE = Style(color=BLUE) # Deploy progress phases PHASE_DONE_STYLE = Style(color="green", bold=True) -PHASE_RUNNING_STYLE = Style(color="blue", bold=True) +PHASE_RUNNING_STYLE = Style(color=BLUE, bold=True) PHASE_FAILED_STYLE = Style(color="red", bold=True) diff --git a/tests/dcm/test_commands.py b/tests/dcm/test_commands.py index c1c356a7fd..26624aefd5 100644 --- a/tests/dcm/test_commands.py +++ b/tests/dcm/test_commands.py @@ -14,13 +14,17 @@ def _analyze_response(files=None): - """Helper to create a JSON analyze response string.""" + """Helper to create a JSON analyze response string. + + Uses the new server response shape: ``issues[]`` arrays with a ``severity`` + field at each level (file / definition / top-level). + """ if files is None: files = [ { "sourcePath": "sources/definitions/ok.sql", - "definitions": [{"name": "OK", "errors": []}], - "errors": [], + "definitions": [{"name": "OK", "issues": []}], + "issues": [], } ] return json.dumps({"files": files}) @@ -76,6 +80,22 @@ def mock_object_manager(): yield _fixture +@pytest.fixture +def mock_deploy_tracker(): + with mock.patch( + "snowflake.cli._plugins.dcm.commands.DeployProgressTracker" + ) as _fixture: + # ``run_loader_phase`` is expected to invoke its first-arg callable + # (the SQL execution closure) and return its result. The real + # implementation does that around a live spinner; in tests we just + # call the closure directly so ``manager.raw_analyze`` / ``manager.plan`` + # actually runs and downstream assertions still hold. + _fixture.return_value.run_loader_phase.side_effect = ( + lambda execute_fn, **_kwargs: execute_fn() + ) + yield _fixture + + @pytest.fixture def mock_project_exists(): with mock.patch( @@ -188,13 +208,17 @@ class TestDCMDeploy: def test_deploy_project( self, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, project_directory, mock_cursor, mock_connect, ): - mock_dcm_manager().deploy.return_value = _plan_cursor(mock_cursor) + mock_dcm_manager().deploy_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( + mock_cursor + ) mock_dcm_manager().sync_local_files.return_value = "TMP_STAGE" mock_manifest_load.return_value = _manifest_without_config() @@ -203,7 +227,7 @@ def test_deploy_project( assert result.exit_code == 0, result.output - mock_dcm_manager().deploy.assert_called_once_with( + mock_dcm_manager().deploy_async.assert_called_once_with( project_identifier=FQN.from_string("fooBar"), configuration=None, from_stage="TMP_STAGE", @@ -215,13 +239,17 @@ def test_deploy_project( def test_deploy_project_with_variables( self, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, project_directory, mock_cursor, mock_connect, ): - mock_dcm_manager().deploy.return_value = _plan_cursor(mock_cursor) + mock_dcm_manager().deploy_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( + mock_cursor + ) mock_dcm_manager().sync_local_files.return_value = "TMP_STAGE" mock_manifest_load.return_value = _manifest_without_config() @@ -229,7 +257,7 @@ def test_deploy_project_with_variables( result = runner.invoke(["dcm", "deploy", "fooBar", "-D", "key=value"]) assert result.exit_code == 0, result.output - mock_dcm_manager().deploy.assert_called_once_with( + mock_dcm_manager().deploy_async.assert_called_once_with( project_identifier=FQN.from_string("fooBar"), configuration=None, from_stage="TMP_STAGE", @@ -241,13 +269,17 @@ def test_deploy_project_with_variables( def test_deploy_project_with_alias( self, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, project_directory, mock_cursor, mock_connect, ): - mock_dcm_manager().deploy.return_value = _plan_cursor(mock_cursor) + mock_dcm_manager().deploy_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( + mock_cursor + ) mock_dcm_manager().sync_local_files.return_value = "TMP_STAGE" mock_manifest_load.return_value = _manifest_without_config() @@ -255,7 +287,7 @@ def test_deploy_project_with_alias( result = runner.invoke(["dcm", "deploy", "fooBar", "--alias", "my_alias"]) assert result.exit_code == 0, result.output - mock_dcm_manager().deploy.assert_called_once_with( + mock_dcm_manager().deploy_async.assert_called_once_with( project_identifier=FQN.from_string("fooBar"), configuration=None, from_stage="TMP_STAGE", @@ -269,13 +301,17 @@ def test_deploy_project_with_sync( self, _mock_create, mock_dcm_manager, + mock_deploy_tracker, runner, project_directory, mock_cursor, mock_connect, ): """Test that files are synced to project stage when from_stage is not provided.""" - mock_dcm_manager().deploy.return_value = _plan_cursor(mock_cursor) + mock_dcm_manager().deploy_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( + mock_cursor + ) mock_dcm_manager().sync_local_files.return_value = ( "MockDatabase.MockSchema.DCM_FOOBAR_1234567890_TMP_STAGE" ) @@ -284,13 +320,14 @@ def test_deploy_project_with_sync( result = runner.invoke(["dcm", "deploy", "my_project"]) assert result.exit_code == 0, result.output - call_args = mock_dcm_manager().deploy.call_args + call_args = mock_dcm_manager().deploy_async.call_args assert "DCM_FOOBAR" in call_args.kwargs["from_stage"] assert call_args.kwargs["from_stage"].endswith("_TMP_STAGE") def test_deploy_project_with_from_local_directory( self, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, project_directory, @@ -298,7 +335,10 @@ def test_deploy_project_with_from_local_directory( mock_connect, tmp_path, ): - mock_dcm_manager().deploy.return_value = _plan_cursor(mock_cursor) + mock_dcm_manager().deploy_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( + mock_cursor + ) mock_dcm_manager().sync_local_files.return_value = ( "MockDatabase.MockSchema.DCM_FOOBAR_1234567890_TMP_STAGE" ) @@ -319,21 +359,26 @@ def test_deploy_project_with_from_local_directory( mock_dcm_manager().sync_local_files.assert_called_once_with( project_identifier=FQN.from_string("my_project"), source_directory=str(source_dir), + progress=mock_deploy_tracker.return_value, ) - call_args = mock_dcm_manager().deploy.call_args + call_args = mock_dcm_manager().deploy_async.call_args assert call_args.kwargs["from_stage"].endswith("_TMP_STAGE") def test_deploy_with_target_flag( self, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, project_directory, mock_cursor, mock_connect, ): - mock_dcm_manager().deploy.return_value = _plan_cursor(mock_cursor) + mock_dcm_manager().deploy_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( + mock_cursor + ) mock_dcm_manager().sync_local_files.return_value = "TMP_STAGE" mock_manifest_load.return_value = DCMManifest.from_dict( { @@ -350,7 +395,7 @@ def test_deploy_with_target_flag( result = runner.invoke(["dcm", "deploy", "--target", "dev"]) assert result.exit_code == 0, result.output - mock_dcm_manager().deploy.assert_called_once_with( + mock_dcm_manager().deploy_async.assert_called_once_with( project_identifier=FQN.from_string("my_project"), configuration=None, from_stage="TMP_STAGE", @@ -362,13 +407,17 @@ def test_deploy_with_target_flag( def test_deploy_with_default_target( self, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, project_directory, mock_cursor, mock_connect, ): - mock_dcm_manager().deploy.return_value = _plan_cursor(mock_cursor) + mock_dcm_manager().deploy_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( + mock_cursor + ) mock_dcm_manager().sync_local_files.return_value = "TMP_STAGE" mock_manifest_load.return_value = DCMManifest.from_dict( { @@ -385,7 +434,7 @@ def test_deploy_with_default_target( result = runner.invoke(["dcm", "deploy"]) assert result.exit_code == 0, result.output - mock_dcm_manager().deploy.assert_called_once_with( + mock_dcm_manager().deploy_async.assert_called_once_with( project_identifier=FQN.from_string("my_project"), configuration=None, from_stage="TMP_STAGE", @@ -397,6 +446,7 @@ def test_deploy_with_default_target( def test_deploy_explicit_identifier_still_uses_target_config( self, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, project_directory, @@ -405,7 +455,10 @@ def test_deploy_explicit_identifier_still_uses_target_config( ): """When explicit identifier is provided, it overrides target's project_name but configuration from target should still be applied.""" - mock_dcm_manager().deploy.return_value = _plan_cursor(mock_cursor) + mock_dcm_manager().deploy_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( + mock_cursor + ) mock_dcm_manager().sync_local_files.return_value = "TMP_STAGE" mock_manifest_load.return_value = DCMManifest.from_dict( { @@ -429,7 +482,7 @@ def test_deploy_explicit_identifier_still_uses_target_config( ) assert result.exit_code == 0, result.output - mock_dcm_manager().deploy.assert_called_once_with( + mock_dcm_manager().deploy_async.assert_called_once_with( project_identifier=FQN.from_string("explicit_project"), configuration="DEV_CONFIG", from_stage="TMP_STAGE", @@ -441,13 +494,17 @@ def test_deploy_explicit_identifier_still_uses_target_config( def test_deploy_with_target_uses_configuration( self, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, project_directory, mock_cursor, mock_connect, ): - mock_dcm_manager().deploy.return_value = _plan_cursor(mock_cursor) + mock_dcm_manager().deploy_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( + mock_cursor + ) mock_dcm_manager().sync_local_files.return_value = "TMP_STAGE" mock_manifest_load.return_value = DCMManifest.from_dict( { @@ -469,7 +526,7 @@ def test_deploy_with_target_uses_configuration( result = runner.invoke(["dcm", "deploy", "--target", "dev"]) assert result.exit_code == 0, result.output - mock_dcm_manager().deploy.assert_called_once_with( + mock_dcm_manager().deploy_async.assert_called_once_with( project_identifier=FQN.from_string("my_project"), configuration="DEV_CONFIG", from_stage="TMP_STAGE", @@ -481,6 +538,7 @@ def test_deploy_with_target_uses_configuration( def test_deploy_with_save_output( self, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, mock_cursor, @@ -488,7 +546,8 @@ def test_deploy_with_save_output( tmp_path, ): plan_response = {"version": 2, "changeset": []} - mock_dcm_manager().deploy.return_value = _plan_cursor( + mock_dcm_manager().deploy_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( mock_cursor, json.dumps(plan_response) ) mock_dcm_manager().sync_local_files.return_value = "TMP_STAGE" @@ -504,6 +563,7 @@ def test_deploy_with_save_output( def test_deploy_with_json_formats_returns_response( self, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, mock_cursor, @@ -512,8 +572,9 @@ def test_deploy_with_json_formats_returns_response( format_name, ): plan_response = {"version": 2, "changeset": []} - mock_dcm_manager().deploy.return_value = _mock_cursor_for_format( - mock_cursor, plan_response, format_name + mock_dcm_manager().deploy_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = ( + _mock_cursor_for_format(mock_cursor, plan_response, format_name) ) mock_dcm_manager().sync_local_files.return_value = "TMP_STAGE" mock_manifest_load.return_value = _manifest_without_config() @@ -554,6 +615,7 @@ def test_purge_confirmation_input_validation( user_inputs, expected_prompt_count, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, project_directory, @@ -561,7 +623,10 @@ def test_purge_confirmation_input_validation( mock_connect, ): mock_prompt.side_effect = user_inputs - mock_dcm_manager().purge.return_value = _plan_cursor(mock_cursor) + mock_dcm_manager().purge_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( + mock_cursor + ) mock_manifest_load.return_value = _manifest_without_config() with project_directory("dcm_project"): @@ -571,7 +636,7 @@ def test_purge_confirmation_input_validation( assert result.exit_code == 0, result.output assert mock_prompt.call_count == expected_prompt_count - mock_dcm_manager().purge.assert_called_once_with( + mock_dcm_manager().purge_async.assert_called_once_with( project_identifier=FQN.from_string(project_identifier), alias=None, skip_plan=False, @@ -584,13 +649,17 @@ def test_purge_project_with_alias( self, mock_prompt, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, project_directory, mock_cursor, mock_connect, ): - mock_dcm_manager().purge.return_value = _plan_cursor(mock_cursor) + mock_dcm_manager().purge_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( + mock_cursor + ) mock_manifest_load.return_value = _manifest_without_config() with project_directory("dcm_project"): @@ -600,7 +669,7 @@ def test_purge_project_with_alias( assert result.exit_code == 0, result.output - mock_dcm_manager().purge.assert_called_once_with( + mock_dcm_manager().purge_async.assert_called_once_with( project_identifier=FQN.from_string("fooBar"), alias="my_alias", skip_plan=False, @@ -625,7 +694,7 @@ def test_purge_cancel( result = runner.invoke(["dcm", "purge", "fooBar", "--interactive"]) assert result.exit_code != 0 - mock_dcm_manager().purge.assert_not_called() + mock_dcm_manager().purge_async.assert_not_called() @mock.patch( "snowflake.cli._plugins.dcm.commands.typer.prompt", @@ -635,13 +704,17 @@ def test_purge_with_target_flag( self, mock_prompt, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, project_directory, mock_cursor, mock_connect, ): - mock_dcm_manager().purge.return_value = _plan_cursor(mock_cursor) + mock_dcm_manager().purge_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( + mock_cursor + ) mock_manifest_load.return_value = DCMManifest.from_dict( { "manifest_version": 2, @@ -657,7 +730,7 @@ def test_purge_with_target_flag( result = runner.invoke(["dcm", "purge", "--target", "dev", "--interactive"]) assert result.exit_code == 0, result.output - mock_dcm_manager().purge.assert_called_once_with( + mock_dcm_manager().purge_async.assert_called_once_with( project_identifier=FQN.from_string("my_project"), alias=None, skip_plan=False, @@ -671,13 +744,17 @@ def test_purge_shows_mismatch_message( self, mock_prompt, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, project_directory, mock_cursor, mock_connect, ): - mock_dcm_manager().purge.return_value = _plan_cursor(mock_cursor) + mock_dcm_manager().purge_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( + mock_cursor + ) mock_manifest_load.return_value = _manifest_without_config() with project_directory("dcm_project"): @@ -687,7 +764,7 @@ def test_purge_shows_mismatch_message( assert " Project identifier mismatch" in result.output assert "Expected: fooBar" in result.output assert "provided: wrong_project" in result.output - mock_dcm_manager().purge.assert_called_once() + mock_dcm_manager().purge_async.assert_called_once() @mock.patch( "snowflake.cli._plugins.dcm.commands.typer.prompt", return_value="purge fooBar" @@ -696,6 +773,7 @@ def test_purge_with_save_output( self, mock_prompt, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, mock_cursor, @@ -703,7 +781,8 @@ def test_purge_with_save_output( tmp_path, ): plan_response = {"version": 2, "changeset": []} - mock_dcm_manager().purge.return_value = _plan_cursor( + mock_dcm_manager().purge_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( mock_cursor, json.dumps(plan_response) ) mock_manifest_load.return_value = _manifest_without_config() @@ -723,13 +802,17 @@ def test_purge_with_force_skips_prompt( mock_confirm_purge, extra_args, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, project_directory, mock_cursor, mock_connect, ): - mock_dcm_manager().purge.return_value = _plan_cursor(mock_cursor) + mock_dcm_manager().purge_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( + mock_cursor + ) mock_manifest_load.return_value = _manifest_without_config() with project_directory("dcm_project"): @@ -737,7 +820,7 @@ def test_purge_with_force_skips_prompt( assert result.exit_code == 0, result.output mock_confirm_purge.assert_not_called() - mock_dcm_manager().purge.assert_called_once_with( + mock_dcm_manager().purge_async.assert_called_once_with( project_identifier=FQN.from_string("fooBar"), alias=None, skip_plan=False, @@ -762,7 +845,7 @@ def test_purge_no_interactive_without_force_fails( "Cannot purge the DCM project non-interactively without --force" in result.output ) - mock_dcm_manager().purge.assert_not_called() + mock_dcm_manager().purge_async.assert_not_called() @mock.patch( "snowflake.cli.api.commands.flags.is_tty_interactive", return_value=False @@ -787,7 +870,7 @@ def test_purge_default_non_tty_without_force_fails( "Cannot purge the DCM project non-interactively without --force" in result.output ) - mock_dcm_manager().purge.assert_not_called() + mock_dcm_manager().purge_async.assert_not_called() @mock.patch( "snowflake.cli.api.commands.flags.is_tty_interactive", return_value=True @@ -800,13 +883,17 @@ def test_purge_default_tty_shows_prompt( mock_prompt, mock_is_tty, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, runner, project_directory, mock_cursor, mock_connect, ): - mock_dcm_manager().purge.return_value = _plan_cursor(mock_cursor) + mock_dcm_manager().purge_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( + mock_cursor + ) mock_manifest_load.return_value = _manifest_without_config() with project_directory("dcm_project"): @@ -814,7 +901,7 @@ def test_purge_default_tty_shows_prompt( assert result.exit_code == 0, result.output mock_prompt.assert_called() - mock_dcm_manager().purge.assert_called_once() + mock_dcm_manager().purge_async.assert_called_once() class TestDCMPlan: @@ -979,6 +1066,7 @@ def test_plan_project_with_from_local_directory( mock_dcm_manager().sync_local_files.assert_called_once_with( project_identifier=FQN.from_string("my_project"), source_directory=str(source_dir), + progress=mock.ANY, ) call_args = mock_dcm_manager().plan.call_args @@ -1078,7 +1166,7 @@ def test_raw_analyze_with_errors_exits( { "sourcePath": "sources/definitions/bad.sql", "definitions": [], - "errors": [{"message": "syntax error"}], + "issues": [{"message": "syntax error", "severity": "ERROR"}], } ] ) @@ -1091,7 +1179,7 @@ def test_raw_analyze_with_errors_exits( with project_directory("dcm_project"): result = runner.invoke(["dcm", "raw-analyze", "fooBar"]) assert result.exit_code == 1, result.output - assert "1 error(s)" in result.output + assert "Static analysis of DCM Project files found 1 error." in result.output def test_raw_analyze_with_variables( self, @@ -1347,8 +1435,8 @@ def test_raw_analyze_with_save_output_saves_response( "files": [ { "sourcePath": "sources/definitions/ok.sql", - "definitions": [{"name": "OK", "errors": []}], - "errors": [], + "definitions": [{"name": "OK", "issues": []}], + "issues": [], } ] } @@ -2154,6 +2242,7 @@ def test_create_calls_check_account_identifier( def test_deploy_calls_check_account_identifier( self, mock_dcm_manager, + mock_deploy_tracker, mock_manifest_load, mock_check_account_identifier, runner, @@ -2161,7 +2250,10 @@ def test_deploy_calls_check_account_identifier( mock_cursor, mock_connect, ): - mock_dcm_manager().deploy.return_value = _plan_cursor(mock_cursor) + mock_dcm_manager().deploy_async.return_value = "mock-sfqid" + mock_deploy_tracker.return_value.run_deploy_poll.return_value = _plan_cursor( + mock_cursor + ) mock_dcm_manager().sync_local_files.return_value = "TMP_STAGE" mock_manifest_load.return_value = _manifest_without_config() diff --git a/tests/dcm/test_progress.py b/tests/dcm/test_progress.py index 7dfb98d588..e409d4ddcc 100644 --- a/tests/dcm/test_progress.py +++ b/tests/dcm/test_progress.py @@ -148,6 +148,20 @@ def test_compile_mode_renders_upload_render_compile(self): # neither are the later PLAN/DEPLOY phases. assert all(not line.startswith(("PLAN", "DEPLOY", "ANALYZE")) for line in lines) + def test_purge_mode_plan_and_purge_show_progress_bar(self): + """In purge mode, PLAN and the PURGE-labelled DEPLOY phase must render + a pip-style bar (not a spinner), mirroring the deploy-mode behaviour.""" + tracker = DeployProgressTracker(conn=MagicMock(), operation="purge") + # The internal phase name remains DEPLOY (server-reported); only the + # rendered label changes to PURGE. + tracker._get_phase("DEPLOY").observe_running(50, datetime.now()) # noqa: SLF001 + + rendered = tracker._render().plain # noqa: SLF001 + purge_line = next(line for line in rendered.split("\n") if "PURGE" in line) + + assert "━" in purge_line + assert " 50%" in purge_line + def test_no_details_block_when_context_is_unset(self): tracker = self._tracker(with_context=False)