diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 609d0e39a9..f02fe074c6 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -21,6 +21,7 @@ ## New additions * Added a `--secondary-roles` option (plus matching `SNOWFLAKE_SECONDARY_ROLES` env var and `secondary_roles` config key) to `snow connection add` and the global connection overrides. The value is forwarded to `snowflake-connector-python` and accepts `ALL` or `NONE`, so sessions can be pinned to the primary role without running an extra `USE SECONDARY ROLES` statement. * Added `--force` flag to `snow spcs service drop` to allow dropping services that contain block storage volumes. +* Added `--preview` flag to `snow streamlit deploy`. Bundles local artifacts and uploads them to a per-entity subfolder under the user's default workspace live version (`snow://workspace/USER$.PUBLIC.DEFAULT$/versions/live//`), then creates the Streamlit at `user$.public.` with `CREATE_CODE_STAGE = FALSE`. Incompatible with `--legacy` and `--prune`. ## Fixes and improvements * Fixed `SELECT *` output being corrupted when joined tables share column names. Duplicate column names are now disambiguated by appending a numeric suffix (e.g. `NAME`, `NAME_2`). diff --git a/src/snowflake/cli/_plugins/streamlit/commands.py b/src/snowflake/cli/_plugins/streamlit/commands.py index 1c8ef10ccb..7240c75099 100644 --- a/src/snowflake/cli/_plugins/streamlit/commands.py +++ b/src/snowflake/cli/_plugins/streamlit/commands.py @@ -44,7 +44,7 @@ from snowflake.cli.api.console.console import CliConsole from snowflake.cli.api.constants import ObjectType from snowflake.cli.api.entities.utils import EntityActions -from snowflake.cli.api.exceptions import NoProjectDefinitionError +from snowflake.cli.api.exceptions import CliError, NoProjectDefinitionError from snowflake.cli.api.identifiers import FQN from snowflake.cli.api.output.types import ( CommandResult, @@ -134,6 +134,19 @@ def _check_file_exists_if_not_default(ctx: click.Context, value): is_flag=True, ) +PreviewOption = typer.Option( + False, + "--preview", + help=( + "Deploy the Streamlit as a personal preview in `user$.public.` " + "sourced from your default workspace at " + "`snow://workspace/USER$.PUBLIC.DEFAULT$/versions/live`. " + "Local artifacts are uploaded to a per-entity subfolder under that " + "workspace live version. Incompatible with `--legacy` and `--prune`." + ), + is_flag=True, +) + @app.command("deploy", requires_connection=True) @with_project_definition() @@ -147,6 +160,7 @@ def streamlit_deploy( entity_id: str = entity_argument("streamlit"), open_: bool = OpenOption, legacy: bool = LegacyOption, + preview: bool = PreviewOption, **options, ) -> CommandResult: """ @@ -156,6 +170,14 @@ def streamlit_deploy( in snowflake.yml and no entity_id is provided then command will raise an error. """ + if preview and legacy: + raise CliError("--preview is not compatible with --legacy.") + if preview and prune: + raise CliError( + "--prune is not supported with --preview because the workspace " + "live version is shared with other files." + ) + cli_context = get_cli_context() workspace_ctx = _get_current_workspace_context() @@ -194,6 +216,7 @@ def streamlit_deploy( replace=replace, legacy=legacy, prune=prune, + preview=preview, ) if open_: diff --git a/src/snowflake/cli/_plugins/streamlit/streamlit_entity.py b/src/snowflake/cli/_plugins/streamlit/streamlit_entity.py index 8481a32be7..adef52b87b 100644 --- a/src/snowflake/cli/_plugins/streamlit/streamlit_entity.py +++ b/src/snowflake/cli/_plugins/streamlit/streamlit_entity.py @@ -8,6 +8,9 @@ from snowflake.cli._plugins.stage.manager import StageManager from snowflake.cli._plugins.streamlit.manager import StreamlitManager from snowflake.cli._plugins.streamlit.streamlit_entity_model import ( + PREVIEW_DATABASE, + PREVIEW_SCHEMA, + PREVIEW_WORKSPACE_STAGE_URI, SPCS_RUNTIME_V2_NAME, StreamlitEntityModel, ) @@ -99,14 +102,23 @@ def deploy( prune: bool = False, bundle_map: Optional[BundleMap] = None, legacy: bool = False, + preview: bool = False, *args, **kwargs, ): + if preview and legacy: + raise CliError("--preview is not compatible with --legacy.") + if preview and prune: + raise CliError("--prune is not supported with --preview.") + if ( bundle_map is None ): # TODO: maybe we could hold bundle map as a cached property? bundle_map = self.bundle() + if preview: + return self._deploy_preview(bundle_map=bundle_map, replace=replace) + console = self._workspace_ctx.console console.step(f"Checking if object exists") object_exists = self._object_exists() @@ -220,6 +232,50 @@ def get_deploy_sql( return query + ";" + def _preview_identifier(self) -> str: + """Personal-preview FQN as a bare identifier (no IDENTIFIER('...') wrap). + + Wrapping in ``IDENTIFIER('...')`` interacts awkwardly with the ``$`` + sigil in ``user$``, so we interpolate directly. + """ + return f"{PREVIEW_DATABASE}.{PREVIEW_SCHEMA}.{self._entity_model.fqn.name}" + + def get_preview_deploy_sql(self, replace: bool = False) -> str: + """Build the CREATE STREAMLIT statement for a personal-preview deploy. + + ``MAIN_FILE`` is auto-prefixed with the entity_id so it points at the + per-entity workspace subfolder where artifacts were uploaded. + """ + verb = "CREATE OR REPLACE STREAMLIT" if replace else "CREATE STREAMLIT" + main_file_with_prefix = f"{self.entity_id}/{self._entity_model.main_file}" + + parts = [ + f"{verb} {self._preview_identifier()}", + f"FROM '{PREVIEW_WORKSPACE_STAGE_URI}'", + f"MAIN_FILE = '{main_file_with_prefix}'", + ] + + if self.model.imports: + parts.append(self.model.get_imports_sql()) + if self.model.query_warehouse: + parts.append(f"QUERY_WAREHOUSE = {self.model.query_warehouse}") + if self.model.title: + parts.append(f"TITLE = '{self.model.title}'") + if self.model.comment: + parts.append(f"COMMENT = '{self.model.comment}'") + if self.model.external_access_integrations: + parts.append(self.model.get_external_access_integrations_sql()) + if self.model.secrets: + parts.append(self.model.get_secrets_sql()) + + parts.append("CREATE_CODE_STAGE = FALSE") + + if self._is_spcs_runtime_v2_mode(): + parts.append(f"RUNTIME_NAME = '{self.model.runtime_name}'") + parts.append(f"COMPUTE_POOL = '{self.model.compute_pool}'") + + return "\n".join(parts) + ";" + def get_describe_sql(self) -> str: return f"DESCRIBE STREAMLIT {self._get_sql_identifier()};" @@ -325,3 +381,89 @@ def _deploy_versioned( ) StreamlitManager(connection=self._conn).grant_privileges(self.model) + + def _preview_object_exists(self) -> bool: + """Existence check against the preview FQN (``user$.public.``). + + Cannot reuse ``_object_exists`` because that uses the yaml FQN. + """ + sql = f"DESCRIBE STREAMLIT {self._preview_identifier()};" + try: + self._execute_query(sql, cursor_class=DictCursor) + return True + except ProgrammingError: + return False + + def _get_preview_url(self) -> str: + # `user$` is a server-side alias resolved to `user$` + # at SQL execution time. Snowsight URLs need the resolved name, so + # query CURRENT_USER() here and fall back to the literal `user$` + # if resolution fails (URL is informational; deploy already succeeded). + database = PREVIEW_DATABASE + try: + cursor = self._execute_query( + "SELECT 'USER$' || CURRENT_USER() AS personal_database" + ) + row = cursor.fetchone() + # Empty CURRENT_USER() yields the literal 'USER$'; treat as unresolved. + if row and row[0] and not row[0].endswith("$"): + database = str(row[0]) + except Exception: # noqa: BLE001 + log.warning( + "Could not resolve personal database; falling back to %r in URL.", + PREVIEW_DATABASE, + exc_info=True, + ) + + preview_fqn = FQN( + database=database, + schema=PREVIEW_SCHEMA, + name=self._entity_model.fqn.name, + ).using_connection(self._conn) + return make_snowsight_url( + self._conn, f"/#/streamlit-apps/{preview_fqn.url_identifier}" + ) + + def _deploy_preview(self, bundle_map: BundleMap, replace: bool = False) -> str: + console = self._workspace_ctx.console + preview_identifier = self._preview_identifier() + + console.step(f"Checking if preview Streamlit {preview_identifier} exists") + if self._preview_object_exists() and not replace: + raise ClickException( + f"Preview Streamlit {preview_identifier} already exists. " + f"Use --replace to overwrite." + ) + + workspace_subfolder = f"{PREVIEW_WORKSPACE_STAGE_URI}/{self.entity_id}" + console.step(f"Uploading artifacts to {workspace_subfolder}") + sync_deploy_root_with_stage( + console=console, + deploy_root=bundle_map.deploy_root(), + bundle_map=bundle_map, + prune=False, # workspace HEAD is shared; never prune unrelated files + recursive=True, + stage_path_parts=StageManager().stage_path_parts_from_str( + workspace_subfolder + ), + print_diff=True, + force_overwrite=True, + ) + + console.step(f"Creating preview Streamlit {preview_identifier}") + self._execute_query(self.get_preview_deploy_sql(replace=replace)) + + # `grants:` from snowflake.yml target the YAML FQN, not the preview + # FQN, so applying them here would either fail with "object does not + # exist" or silently grant on the wrong object. Preview deploys live + # in `user$.public.`, which is private to the current user; + # users can apply grants manually if needed. + if self.model.grants: + console.warning( + "Skipping `grants:` in --preview mode. Preview deploys to " + f"{preview_identifier}, which is private to your user; apply " + "grants manually if needed." + ) + else: + StreamlitManager(connection=self._conn).grant_privileges(self.model) + return self._get_preview_url() diff --git a/src/snowflake/cli/_plugins/streamlit/streamlit_entity_model.py b/src/snowflake/cli/_plugins/streamlit/streamlit_entity_model.py index 341d6f8960..fc313ed225 100644 --- a/src/snowflake/cli/_plugins/streamlit/streamlit_entity_model.py +++ b/src/snowflake/cli/_plugins/streamlit/streamlit_entity_model.py @@ -28,6 +28,11 @@ # SPCS Runtime v2 constants SPCS_RUNTIME_V2_NAME = "SYSTEM$ST_CONTAINER_RUNTIME_PY3_11" +# Personal-preview deploy constants (used by `snow streamlit deploy --preview`) +PREVIEW_DATABASE = "user$" +PREVIEW_SCHEMA = "public" +PREVIEW_WORKSPACE_STAGE_URI = "snow://workspace/USER$.PUBLIC.DEFAULT$/versions/live" + class StreamlitEntityModel( EntityModelBaseWithArtifacts, diff --git a/tests/streamlit/test_streamlit_commands.py b/tests/streamlit/test_streamlit_commands.py index 0fe5b84b77..01dd7382f3 100644 --- a/tests/streamlit/test_streamlit_commands.py +++ b/tests/streamlit/test_streamlit_commands.py @@ -556,3 +556,74 @@ def test_execute_streamlit(self, runner, mock_streamlit_ctx): assert mock_streamlit_ctx.get_queries() == [ "EXECUTE STREAMLIT IDENTIFIER('test_streamlit')()" ] + + # ---- Personal-preview deploy (`--preview`) ---- + + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.StreamlitEntity._preview_object_exists", + return_value=False, + ) + def test_deploy_preview_with_spcs_runtime( + self, _mock_preview_exists, project_directory, runner, alter_snowflake_yml + ): + """End-to-end: --preview emits the workspace-FROM SQL with SPCS runtime + clauses and uploads files to the per-entity workspace subfolder.""" + with project_directory("example_streamlit_v2") as tmp_dir: + alter_snowflake_yml( + tmp_dir / "snowflake.yml", + parameter_path="entities.test_streamlit.runtime_name", + value="SYSTEM$ST_CONTAINER_RUNTIME_PY3_11", + ) + alter_snowflake_yml( + tmp_dir / "snowflake.yml", + parameter_path="entities.test_streamlit.compute_pool", + value="MYPOOL", + ) + result = runner.invoke(["streamlit", "deploy", "--preview", "--replace"]) + + expected_query = dedent( + """ + CREATE OR REPLACE STREAMLIT user$.public.test_streamlit + FROM 'snow://workspace/USER$.PUBLIC.DEFAULT$/versions/live' + MAIN_FILE = 'test_streamlit/streamlit_app.py' + QUERY_WAREHOUSE = test_warehouse + TITLE = 'My Fancy Streamlit' + CREATE_CODE_STAGE = FALSE + RUNTIME_NAME = 'SYSTEM$ST_CONTAINER_RUNTIME_PY3_11' + COMPUTE_POOL = 'MYPOOL'; + """ + ).strip() + + assert result.exit_code == 0, result.output + self.mock_execute.assert_any_call(expected_query) + + # No ADD LIVE VERSION should be issued for preview mode. + executed = [c.args[0] for c in self.mock_execute.call_args_list] + assert not any("ADD LIVE VERSION" in q for q in executed), executed + + # Files must be uploaded under the per-entity workspace subfolder. + upload_destinations = [ + call.kwargs.get("stage_path") + or (call.args[1] if len(call.args) > 1 else None) + for call in self.mock_put.call_args_list + ] + assert any( + d + and "snow://workspace/USER$.PUBLIC.DEFAULT$/versions/live/test_streamlit" + in d + for d in upload_destinations + ), upload_destinations + + def test_deploy_preview_with_prune_errors(self, project_directory, runner): + with project_directory("example_streamlit_v2"): + result = runner.invoke(["streamlit", "deploy", "--preview", "--prune"]) + + assert result.exit_code != 0, result.output + assert "--prune is not supported with --preview" in result.output + + def test_deploy_preview_with_legacy_errors(self, project_directory, runner): + with project_directory("example_streamlit_v2"): + result = runner.invoke(["streamlit", "deploy", "--preview", "--legacy"]) + + assert result.exit_code != 0, result.output + assert "--preview is not compatible with --legacy" in result.output diff --git a/tests/streamlit/test_streamlit_entity.py b/tests/streamlit/test_streamlit_entity.py index 4e0debda41..c006f11fdf 100644 --- a/tests/streamlit/test_streamlit_entity.py +++ b/tests/streamlit/test_streamlit_entity.py @@ -4,6 +4,9 @@ import pytest from snowflake.cli._plugins.streamlit.streamlit_entity import StreamlitEntity from snowflake.cli._plugins.streamlit.streamlit_entity_model import ( + PREVIEW_DATABASE, + PREVIEW_SCHEMA, + PREVIEW_WORKSPACE_STAGE_URI, SPCS_RUNTIME_V2_NAME, StreamlitEntityModel, ) @@ -533,3 +536,301 @@ def test_replace_versioned_with_legacy_shows_warning( "Deployment style is changing from versioned to legacy" in str(call) for call in workspace_context.console.warning.call_args_list ) + + # ---- Personal-preview deploy (`--preview`) ---- + + def _build_preview_entity( + self, + workspace_context, + *, + entity_id: str = "streamlittestapp", + identifier_name: str = "testapp_vnext_private", + main_file: str = "streamlit_app.py", + query_warehouse: str = "REGRESS", + runtime_name: str | None = SPCS_RUNTIME_V2_NAME, + compute_pool: str | None = "MYPOOL", + title: str | None = None, + comment: str | None = None, + ) -> StreamlitEntity: + kwargs = { + "type": "streamlit", + "identifier": identifier_name, + "main_file": main_file, + "artifacts": [main_file], + "query_warehouse": query_warehouse, + } + if runtime_name is not None: + kwargs["runtime_name"] = runtime_name + if compute_pool is not None: + kwargs["compute_pool"] = compute_pool + if title is not None: + kwargs["title"] = title + if comment is not None: + kwargs["comment"] = comment + model = StreamlitEntityModel(**kwargs) + model.set_entity_id(entity_id) + return StreamlitEntity(workspace_ctx=workspace_context, entity_model=model) + + def test_get_preview_deploy_sql_with_spcs_runtime(self, workspace_context): + """SPCS-runtime preview deploy must match the user's example SQL exactly.""" + entity = self._build_preview_entity(workspace_context) + + sql = entity.get_preview_deploy_sql(replace=True) + + assert sql == ( + "CREATE OR REPLACE STREAMLIT user$.public.testapp_vnext_private\n" + "FROM 'snow://workspace/USER$.PUBLIC.DEFAULT$/versions/live'\n" + "MAIN_FILE = 'streamlittestapp/streamlit_app.py'\n" + "QUERY_WAREHOUSE = REGRESS\n" + "CREATE_CODE_STAGE = FALSE\n" + "RUNTIME_NAME = 'SYSTEM$ST_CONTAINER_RUNTIME_PY3_11'\n" + "COMPUTE_POOL = 'MYPOOL';" + ) + + def test_get_preview_deploy_sql_without_spcs_runtime(self, workspace_context): + """Warehouse-runtime preview omits SPCS clauses but keeps CREATE_CODE_STAGE = FALSE.""" + entity = self._build_preview_entity( + workspace_context, + runtime_name=None, + compute_pool=None, + ) + + sql = entity.get_preview_deploy_sql(replace=True) + + assert "RUNTIME_NAME" not in sql + assert "COMPUTE_POOL" not in sql + assert "CREATE_CODE_STAGE = FALSE" in sql + assert sql.startswith( + "CREATE OR REPLACE STREAMLIT user$.public.testapp_vnext_private" + ) + + def test_get_preview_deploy_sql_replace_false(self, workspace_context): + entity = self._build_preview_entity(workspace_context) + sql = entity.get_preview_deploy_sql(replace=False) + assert sql.startswith("CREATE STREAMLIT user$.public.testapp_vnext_private") + assert "CREATE OR REPLACE" not in sql + + def test_get_preview_deploy_sql_main_file_prefix(self, workspace_context): + """MAIN_FILE is auto-prefixed with the entity_id regardless of yaml main_file.""" + entity = self._build_preview_entity( + workspace_context, + entity_id="my_subdir", + main_file="nested/app.py", + ) + sql = entity.get_preview_deploy_sql(replace=False) + assert "MAIN_FILE = 'my_subdir/nested/app.py'" in sql + + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.sync_deploy_root_with_stage" + ) + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.StreamlitEntity._preview_object_exists" + ) + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.StreamlitEntity.bundle" + ) + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.StreamlitEntity._get_preview_url" + ) + def test_deploy_with_preview_uploads_to_workspace_subfolder( + self, + mock_url, + mock_bundle, + mock_exists, + mock_sync, + workspace_context, + action_context, + ): + """sync_deploy_root_with_stage receives the per-entity workspace subfolder.""" + mock_exists.return_value = False + mock_url.return_value = "https://snowflake.com/preview" + mock_bundle.return_value = BundleMap( + project_root=workspace_context.project_root, + deploy_root=workspace_context.project_root / "output", + ) + entity = self._build_preview_entity(workspace_context) + + entity.action_deploy(action_context, _open=False, replace=True, preview=True) + + assert mock_sync.call_count == 1 + kwargs = mock_sync.call_args.kwargs + # Stage-path-parts should be derived from + # snow://workspace/USER$.PUBLIC.DEFAULT$/versions/live/streamlittestapp + assert "streamlittestapp" in kwargs["stage_path_parts"].full_path + assert PREVIEW_WORKSPACE_STAGE_URI in kwargs["stage_path_parts"].full_path + # Pruning is forced off because workspace HEAD is shared. + assert kwargs["prune"] is False + + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.sync_deploy_root_with_stage" + ) + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.StreamlitEntity._preview_object_exists" + ) + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.StreamlitEntity.bundle" + ) + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.StreamlitEntity._get_preview_url" + ) + def test_deploy_with_preview_does_not_call_add_live_version( + self, + mock_url, + mock_bundle, + mock_exists, + mock_sync, + workspace_context, + action_context, + ): + mock_exists.return_value = False + mock_url.return_value = "https://snowflake.com/preview" + mock_bundle.return_value = BundleMap( + project_root=workspace_context.project_root, + deploy_root=workspace_context.project_root / "output", + ) + entity = self._build_preview_entity(workspace_context) + + entity.action_deploy(action_context, _open=False, replace=True, preview=True) + + executed_queries = [c.args[0] for c in self.mock_execute.call_args_list] + assert not any("ADD LIVE VERSION" in q for q in executed_queries), ( + f"ADD LIVE VERSION should never be issued in preview mode. " + f"Queries: {executed_queries}" + ) + # The preview CREATE STREAMLIT must be the executed query. + assert any( + q.startswith( + "CREATE OR REPLACE STREAMLIT user$.public.testapp_vnext_private" + ) + for q in executed_queries + ) + + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.sync_deploy_root_with_stage" + ) + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.StreamlitEntity._preview_object_exists" + ) + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.StreamlitEntity.bundle" + ) + def test_deploy_with_preview_object_exists_no_replace_errors( + self, + mock_bundle, + mock_exists, + mock_sync, + workspace_context, + action_context, + ): + from click import ClickException + + mock_exists.return_value = True + mock_bundle.return_value = BundleMap( + project_root=workspace_context.project_root, + deploy_root=workspace_context.project_root / "output", + ) + entity = self._build_preview_entity(workspace_context) + + with pytest.raises(ClickException, match="already exists"): + entity.action_deploy( + action_context, _open=False, replace=False, preview=True + ) + # Upload must not have happened. + mock_sync.assert_not_called() + + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.StreamlitEntity.bundle" + ) + def test_deploy_with_preview_and_legacy_raises_error( + self, mock_bundle, workspace_context, action_context + ): + mock_bundle.return_value = BundleMap( + project_root=workspace_context.project_root, + deploy_root=workspace_context.project_root / "output", + ) + entity = self._build_preview_entity(workspace_context) + + with pytest.raises(CliError, match="--preview is not compatible with --legacy"): + entity.action_deploy( + action_context, + _open=False, + replace=True, + preview=True, + legacy=True, + ) + + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.StreamlitEntity.bundle" + ) + def test_deploy_with_preview_and_prune_raises_error( + self, mock_bundle, workspace_context, action_context + ): + mock_bundle.return_value = BundleMap( + project_root=workspace_context.project_root, + deploy_root=workspace_context.project_root / "output", + ) + entity = self._build_preview_entity(workspace_context) + + with pytest.raises(CliError, match="--prune is not supported with --preview"): + entity.action_deploy( + action_context, + _open=False, + replace=True, + preview=True, + prune=True, + ) + + def test_preview_constants_match_user_example(self): + assert PREVIEW_DATABASE == "user$" + assert PREVIEW_SCHEMA == "public" + assert ( + PREVIEW_WORKSPACE_STAGE_URI + == "snow://workspace/USER$.PUBLIC.DEFAULT$/versions/live" + ) + + @mock.patch("snowflake.cli._plugins.streamlit.streamlit_entity.StreamlitManager") + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.sync_deploy_root_with_stage" + ) + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.StreamlitEntity._preview_object_exists", + return_value=False, + ) + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.StreamlitEntity.bundle" + ) + @mock.patch( + "snowflake.cli._plugins.streamlit.streamlit_entity.StreamlitEntity._get_preview_url", + return_value="https://snowflake.com/preview", + ) + def test_deploy_with_preview_skips_grants_when_yaml_has_grants( + self, + _mock_url, + mock_bundle, + _mock_exists, + _mock_sync, + mock_streamlit_manager, + workspace_context, + action_context, + ): + """`grants:` from snowflake.yml target the YAML FQN, not the preview FQN. + + Applying them in preview mode would either fail or silently grant on + the wrong object, so `_deploy_preview` must skip grants and warn. + """ + mock_bundle.return_value = BundleMap( + project_root=workspace_context.project_root, + deploy_root=workspace_context.project_root / "output", + ) + from snowflake.cli.api.project.schemas.entities.common import Grant + + entity = self._build_preview_entity(workspace_context) + entity.model.grants = [Grant(privilege="USAGE", role="SOME_ROLE")] + + entity.action_deploy(action_context, _open=False, replace=True, preview=True) + + mock_streamlit_manager.return_value.grant_privileges.assert_not_called() + assert any( + "Skipping `grants:` in --preview mode" in str(call) + for call in workspace_context.console.warning.call_args_list + )