diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 38e3617741..06b1762467 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -40,6 +40,7 @@ every Snowpark stage specified in project definition. * The changes are compatible with V1 projects definition though the result state (file layout) is different. * `snow snowpark package` commands no longer fallback to Anaconda Channel metadata when fetching available packages info fails. + * Added `snow streamlit execute app-name` command to run Streamlit apps in a Snowflake environment in headless mode. ## Deprecations * Renamed `private-key-path` flag to `private-key-file`, added `private-key-path` as an alias for backward compatibility. @@ -57,6 +58,7 @@ * Added periodic check for newest version of Snowflake CLI. When new version is available, user will be notified. * Added support for `imports` in Streamlit definition. + ## Fixes and improvements * Fixed problem with whitespaces in `snow connection add` command. * Added check for the correctness of token file and private key paths when addind a connection. diff --git a/src/snowflake/cli/_plugins/streamlit/commands.py b/src/snowflake/cli/_plugins/streamlit/commands.py index 1d7ef2d571..9449cbc66a 100644 --- a/src/snowflake/cli/_plugins/streamlit/commands.py +++ b/src/snowflake/cli/_plugins/streamlit/commands.py @@ -81,6 +81,18 @@ ) +@app.command(requires_connection=True) +def execute( + name: FQN = StreamlitNameArgument, + **options, +): + """ + Executes a streamlit in a headless mode. + """ + _ = StreamlitManager().execute(app_name=name) + return MessageResult(f"Streamlit {name} executed.") + + @app.command("share", requires_connection=True) def streamlit_share( name: FQN = StreamlitNameArgument, diff --git a/src/snowflake/cli/_plugins/streamlit/manager.py b/src/snowflake/cli/_plugins/streamlit/manager.py index 1c77a78121..e7b27a9ae6 100644 --- a/src/snowflake/cli/_plugins/streamlit/manager.py +++ b/src/snowflake/cli/_plugins/streamlit/manager.py @@ -43,6 +43,10 @@ class StreamlitManager(SqlExecutionMixin): + def execute(self, app_name: FQN): + query = f"EXECUTE STREAMLIT {app_name.sql_identifier}()" + return self._execute_query(query=query) + def share(self, streamlit_name: FQN, to_role: str) -> SnowflakeCursor: return self._execute_query( f"grant usage on streamlit {streamlit_name.sql_identifier} to role {to_role}" diff --git a/tests/__snapshots__/test_help_messages.ambr b/tests/__snapshots__/test_help_messages.ambr index 1dcad8ebd7..2234377af6 100644 --- a/tests/__snapshots__/test_help_messages.ambr +++ b/tests/__snapshots__/test_help_messages.ambr @@ -7815,6 +7815,82 @@ +------------------------------------------------------------------------------+ + ''' +# --- +# name: test_help_messages[streamlit.execute] + ''' + + Usage: default streamlit execute [OPTIONS] NAME + + Executes a streamlit in a headless mode. + + +- Arguments ------------------------------------------------------------------+ + | * name TEXT Identifier of the Streamlit app. For example: | + | my_streamlit | + | [required] | + +------------------------------------------------------------------------------+ + +- Options --------------------------------------------------------------------+ + | --help -h Show this message and exit. | + +------------------------------------------------------------------------------+ + +- Connection configuration ---------------------------------------------------+ + | --connection,--environment -c TEXT Name of the connection, as | + | defined in your config.toml. | + | Default: default. | + | --account,--accountname TEXT Name assigned to your | + | Snowflake account. Overrides | + | the value specified for the | + | connection. | + | --user,--username TEXT Username to connect to | + | Snowflake. Overrides the value | + | specified for the connection. | + | --password TEXT Snowflake password. Overrides | + | the value specified for the | + | connection. | + | --authenticator TEXT Snowflake authenticator. | + | Overrides the value specified | + | for the connection. | + | --private-key-file,--private-… TEXT Snowflake private key file | + | path. Overrides the value | + | specified for the connection. | + | --token-file-path TEXT Path to file with an OAuth | + | token that should be used when | + | connecting to Snowflake | + | --database,--dbname TEXT Database to use. Overrides the | + | value specified for the | + | connection. | + | --schema,--schemaname TEXT Database schema to use. | + | Overrides the value specified | + | for the connection. | + | --role,--rolename TEXT Role to use. Overrides the | + | value specified for the | + | connection. | + | --warehouse TEXT Warehouse to use. Overrides | + | the value specified for the | + | connection. | + | --temporary-connection -x Uses connection defined with | + | command line parameters, | + | instead of one defined in | + | config | + | --mfa-passcode TEXT Token to use for multi-factor | + | authentication (MFA) | + | --enable-diag Run python connector | + | diagnostic test | + | --diag-log-path TEXT Diagnostic report path | + | --diag-allowlist-path TEXT Diagnostic report path to | + | optional allowlist | + +------------------------------------------------------------------------------+ + +- Global configuration -------------------------------------------------------+ + | --format [TABLE|JSON] Specifies the output format. | + | [default: TABLE] | + | --verbose -v Displays log entries for log levels info | + | and higher. | + | --debug Displays log entries for log levels debug | + | and higher; debug logs contains additional | + | information. | + | --silent Turns off intermediate output to console. | + +------------------------------------------------------------------------------+ + + ''' # --- # name: test_help_messages[streamlit.get-url] @@ -8073,6 +8149,7 @@ | command will raise an error. | | describe Provides description of streamlit. | | drop Drops streamlit with given name. | + | execute Executes a streamlit in a headless mode. | | get-url Returns a URL to the specified Streamlit app | | list Lists all available streamlits. | | share Shares a Streamlit app with another role. | @@ -8494,6 +8571,7 @@ | command will raise an error. | | describe Provides description of streamlit. | | drop Drops streamlit with given name. | + | execute Executes a streamlit in a headless mode. | | get-url Returns a URL to the specified Streamlit app | | list Lists all available streamlits. | | share Shares a Streamlit app with another role. | diff --git a/tests/stage/__snapshots__/test_stage.ambr b/tests/stage/__snapshots__/test_stage.ambr index af8887291c..f3b8576c06 100644 --- a/tests/stage/__snapshots__/test_stage.ambr +++ b/tests/stage/__snapshots__/test_stage.ambr @@ -34,17 +34,6 @@ ''' # --- -# name: test_execute[@DB.SCHEMA.EXE/s1.sql-@db.schema.exe-expected_files19] - ''' - SUCCESS - @db.schema.exe/s1.sql - +-----------------------------------------+ - | File | Status | Error | - |-----------------------+---------+-------| - | @db.schema.exe/s1.sql | SUCCESS | None | - +-----------------------------------------+ - - ''' -# --- # name: test_execute[@DB.schema.EXE/a/S3.sql-@DB.schema.EXE-expected_files20] ''' SUCCESS - @DB.schema.EXE/a/S3.sql @@ -56,17 +45,6 @@ ''' # --- -# name: test_execute[@DB.schema.EXE/a/S3.sql-@db.schema.exe-expected_files20] - ''' - SUCCESS - @db.schema.exe/a/S3.sql - +-------------------------------------------+ - | File | Status | Error | - |-------------------------+---------+-------| - | @db.schema.exe/a/S3.sql | SUCCESS | None | - +-------------------------------------------+ - - ''' -# --- # name: test_execute[@db.schema.exe-@db.schema.exe-expected_files15] ''' SUCCESS - @db.schema.exe/s1.sql diff --git a/tests/streamlit/test_commands.py b/tests/streamlit/test_commands.py index 9e22811744..92065e3628 100644 --- a/tests/streamlit/test_commands.py +++ b/tests/streamlit/test_commands.py @@ -18,6 +18,8 @@ import pytest from snowflake.cli._plugins.connection.util import REGIONLESS_QUERY +from snowflake.cli._plugins.streamlit.manager import StreamlitManager +from snowflake.cli.api.identifiers import FQN STREAMLIT_NAME = "test_streamlit" TEST_WAREHOUSE = "test_warehouse" @@ -549,12 +551,15 @@ def test_deploy_streamlit_main_and_pages_files_experimental( ) mock_connector.return_value = ctx - with mock.patch( - "snowflake.cli.api.feature_flags.FeatureFlag.ENABLE_STREAMLIT_VERSIONED_STAGE.is_enabled", - return_value=enable_streamlit_versioned_stage, - ), mock.patch( - "snowflake.cli.api.feature_flags.FeatureFlag.ENABLE_STREAMLIT_NO_CHECKOUTS.is_enabled", - return_value=enable_streamlit_no_checkouts, + with ( + mock.patch( + "snowflake.cli.api.feature_flags.FeatureFlag.ENABLE_STREAMLIT_VERSIONED_STAGE.is_enabled", + return_value=enable_streamlit_versioned_stage, + ), + mock.patch( + "snowflake.cli.api.feature_flags.FeatureFlag.ENABLE_STREAMLIT_NO_CHECKOUTS.is_enabled", + return_value=enable_streamlit_no_checkouts, + ), ): with project_directory("example_streamlit"): result = runner.invoke(["streamlit", "deploy", "--experimental"]) @@ -937,3 +942,12 @@ def test_deploy_streamlit_with_comment_v2( REGIONLESS_QUERY, "select current_account_name()", ] + + +@mock.patch.object(StreamlitManager, "execute") +def test_execute_streamlit(mock_execute, runner): + result = runner.invoke(["streamlit", "execute", STREAMLIT_NAME]) + + assert result.exit_code == 0, result.output + assert result.output == f"Streamlit {STREAMLIT_NAME} executed.\n" + mock_execute.assert_called_once_with(app_name=FQN.from_string(STREAMLIT_NAME)) diff --git a/tests/streamlit/test_streamlit_manager.py b/tests/streamlit/test_streamlit_manager.py index 30e52e5ced..da16a951fe 100644 --- a/tests/streamlit/test_streamlit_manager.py +++ b/tests/streamlit/test_streamlit_manager.py @@ -7,6 +7,7 @@ from snowflake.cli._plugins.streamlit.streamlit_entity_model import ( StreamlitEntityModel, ) +from snowflake.cli.api.identifiers import FQN mock_streamlit_exists = mock.patch( "snowflake.cli._plugins.streamlit.manager.ObjectManager.object_exists", @@ -131,3 +132,15 @@ def test_deploy_streamlit_with_comment( COMMENT = 'This is a test comment'""" ) ) + + +@mock.patch("snowflake.cli._plugins.streamlit.manager.StreamlitManager._execute_query") +@mock_streamlit_exists +def test_execute_streamlit(mock_execute_query): + app_name = FQN(database="DB", schema="SH", name="my_streamlit_app") + + StreamlitManager(MagicMock()).execute(app_name=app_name) + + mock_execute_query.assert_called_once_with( + query="EXECUTE STREAMLIT IDENTIFIER('DB.SH.my_streamlit_app')()" + ) diff --git a/tests_integration/test_streamlit.py b/tests_integration/test_streamlit.py index 0da14a626d..b2f89c983d 100644 --- a/tests_integration/test_streamlit.py +++ b/tests_integration/test_streamlit.py @@ -23,6 +23,7 @@ rows_from_snowflake_session, ) from tests_integration.testing_utils import assert_that_result_is_successful +from snowflake.cli._plugins.streamlit.manager import StreamlitManager @pytest.mark.integration @@ -367,3 +368,34 @@ def _new_streamlit_role(snowflake_session, test_database): row_from_snowflake_session(result), {"status": f"{role_name.upper()} successfully dropped."}, ) + + +@pytest.mark.integration +def test_streamlit_execute_in_headless_mode( + runner, + snowflake_session, + project_directory, +): + streamlit_name = "test_streamlit_deploy_snowcli" + + # Deploy the Streamlit app + with project_directory("streamlit_v2"): + result = runner.invoke_with_connection_json( + ["streamlit", "deploy", "--replace"] + ) + assert result.exit_code == 0, f"Streamlit deploy failed: {result.output}" + + # Execute the Streamlit app in headless mode + result = runner.invoke_with_connection_json( + ["streamlit", "execute", streamlit_name] + ) + assert result.exit_code == 0, f"Streamlit execute failed: {result.output}" + assert result.json == {"message": f"Streamlit {streamlit_name} executed."} + + result = runner.invoke_with_connection_json(["streamlit", "drop", streamlit_name]) + assert result.exit_code == 0, f"Streamlit drop failed: {result.output}" + + # Fix: Handle list of dictionaries + assert result.json[0] == { + "status": f"{streamlit_name.upper()} successfully dropped." + }