From 88d9b153a8c7268493a4545b387681678ad5308c Mon Sep 17 00:00:00 2001 From: Patryk Czajka Date: Fri, 31 May 2024 11:41:28 +0200 Subject: [PATCH] Revert "Snow 1064130 refactor plugins to use app factory" (#1149) Revert "Snow 1064130 refactor plugins to use app factory (#1129)" This reverts commit 47f733a93a54ae26a6b181a04d9dfddc084e23af. --- .../cli/plugins/connection/commands.py | 472 +++++------ .../cli/plugins/connection/plugin_spec.py | 2 +- src/snowflake/cli/plugins/cortex/commands.py | 562 +++++++------ .../cli/plugins/cortex/plugin_spec.py | 2 +- src/snowflake/cli/plugins/git/commands.py | 410 +++++----- src/snowflake/cli/plugins/git/plugin_spec.py | 2 +- .../cli/plugins/notebook/commands.py | 94 ++- .../cli/plugins/notebook/plugin_spec.py | 2 +- src/snowflake/cli/plugins/object/__init__.py | 11 + .../cli/plugins/object/command_aliases.py | 8 +- src/snowflake/cli/plugins/object/commands.py | 80 +- .../cli/plugins/object/plugin_spec.py | 4 +- .../object_stage_deprecated/commands.py | 162 ++-- .../object_stage_deprecated/plugin_spec.py | 6 +- .../cli/plugins/snowpark/__init__.py | 4 + .../cli/plugins/snowpark/commands.py | 736 +++++++++--------- .../cli/plugins/snowpark/package/commands.py | 368 +++++---- .../cli/plugins/snowpark/plugin_spec.py | 4 +- src/snowflake/cli/plugins/sql/commands.py | 113 +-- src/snowflake/cli/plugins/sql/plugin_spec.py | 2 +- src/snowflake/cli/plugins/stage/commands.py | 195 ++--- .../cli/plugins/stage/plugin_spec.py | 4 +- .../cli/plugins/streamlit/commands.py | 225 +++--- .../cli/plugins/streamlit/plugin_spec.py | 2 +- .../__snapshots__/test_error_handling.ambr | 21 - tests_e2e/config/malformatted_config.toml | 5 + tests_e2e/test_error_handling.py | 28 - 27 files changed, 1696 insertions(+), 1828 deletions(-) delete mode 100644 tests_e2e/__snapshots__/test_error_handling.ambr create mode 100755 tests_e2e/config/malformatted_config.toml diff --git a/src/snowflake/cli/plugins/connection/commands.py b/src/snowflake/cli/plugins/connection/commands.py index 7336a3e8f0..b2e555e144 100644 --- a/src/snowflake/cli/plugins/connection/commands.py +++ b/src/snowflake/cli/plugins/connection/commands.py @@ -1,5 +1,7 @@ from __future__ import annotations +import logging + import typer from click import ClickException, Context, Parameter # type: ignore from click.core import ParameterSource # type: ignore @@ -30,258 +32,264 @@ from snowflake.connector import ProgrammingError from snowflake.connector.config_manager import CONFIG_MANAGER +app = SnowTyper( + name="connection", + help="Manages connections to Snowflake.", +) +log = logging.getLogger(__name__) + + +class EmptyInput: + def __repr__(self): + return "optional" + + +class OptionalPrompt(StringParamType): + def convert(self, value, param, ctx): + return None if isinstance(value, EmptyInput) else value + -def create_app(): - app = SnowTyper( - name="connection", - help="Manages connections to Snowflake.", +def _mask_password(connection_params: dict): + if "password" in connection_params: + connection_params["password"] = "****" + return connection_params + + +@app.command(name="list") +def list_connections(**options) -> CommandResult: + """ + Lists configured connections. + """ + connections = get_all_connections() + default_connection = get_default_connection_name() + result = ( + { + "connection_name": connection_name, + "parameters": _mask_password( + connection_config.to_dict_of_known_non_empty_values() + ), + "is_default": connection_name == default_connection, + } + for connection_name, connection_config in connections.items() ) + return CollectionResult(result) - class EmptyInput: - def __repr__(self): - return "optional" - class OptionalPrompt(StringParamType): - def convert(self, value, param, ctx): - return None if isinstance(value, EmptyInput) else value +def require_integer(field_name: str): + def callback(value: str): + if value is None: + return None + if value.isdigit(): + return value + raise ClickException(f"Value of {field_name} must be integer") - def _mask_password(connection_params: dict): - if "password" in connection_params: - connection_params["password"] = "****" - return connection_params + return callback - @app.command(name="list") - def list_connections(**options) -> CommandResult: - """ - Lists configured connections. - """ - connections = get_all_connections() - default_connection = get_default_connection_name() - result = ( - { - "connection_name": connection_name, - "parameters": _mask_password( - connection_config.to_dict_of_known_non_empty_values() - ), - "is_default": connection_name == default_connection, - } - for connection_name, connection_config in connections.items() - ) - return CollectionResult(result) - def require_integer(field_name: str): - def callback(value: str): - if value is None: - return None - if value.isdigit(): - return value - raise ClickException(f"Value of {field_name} must be integer") +def _password_callback(ctx: Context, param: Parameter, value: str): + if value and ctx.get_parameter_source(param.name) == ParameterSource.COMMANDLINE: # type: ignore + cli_console.warning(PLAIN_PASSWORD_MSG) - return callback + return value - def _password_callback(ctx: Context, param: Parameter, value: str): - if value and ctx.get_parameter_source(param.name) == ParameterSource.COMMANDLINE: # type: ignore - cli_console.warning(PLAIN_PASSWORD_MSG) - return value +@app.command() +def add( + connection_name: str = typer.Option( + None, + "--connection-name", + "-n", + prompt="Name for this connection", + help="Name of the new connection.", + show_default=False, + ), + account: str = typer.Option( + None, + "--account", + "-a", + "--accountname", + prompt="Snowflake account name", + help="Account name to use when authenticating with Snowflake.", + show_default=False, + ), + user: str = typer.Option( + None, + "--user", + "-u", + "--username", + prompt="Snowflake username", + show_default=False, + help="Username to connect to Snowflake.", + ), + password: str = typer.Option( + EmptyInput(), + "--password", + "-p", + click_type=OptionalPrompt(), + callback=_password_callback, + prompt="Snowflake password", + help="Snowflake password.", + hide_input=True, + ), + role: str = typer.Option( + EmptyInput(), + "--role", + "-r", + click_type=OptionalPrompt(), + prompt="Role for the connection", + help="Role to use on Snowflake.", + ), + warehouse: str = typer.Option( + EmptyInput(), + "--warehouse", + "-w", + click_type=OptionalPrompt(), + prompt="Warehouse for the connection", + help="Warehouse to use on Snowflake.", + ), + database: str = typer.Option( + EmptyInput(), + "--database", + "-d", + click_type=OptionalPrompt(), + prompt="Database for the connection", + help="Database to use on Snowflake.", + ), + schema: str = typer.Option( + EmptyInput(), + "--schema", + "-s", + click_type=OptionalPrompt(), + prompt="Schema for the connection", + help="Schema to use on Snowflake.", + ), + host: str = typer.Option( + EmptyInput(), + "--host", + "-h", + click_type=OptionalPrompt(), + prompt="Connection host", + help="Host name the connection attempts to connect to Snowflake.", + ), + port: int = typer.Option( + EmptyInput(), + "--port", + "-P", + click_type=OptionalPrompt(), + prompt="Connection port", + help="Port to communicate with on the host.", + callback=require_integer(field_name="port"), + ), + region: str = typer.Option( + EmptyInput(), + "--region", + "-R", + click_type=OptionalPrompt(), + prompt="Snowflake region", + help="Region name if not the default Snowflake deployment.", + ), + authenticator: str = typer.Option( + EmptyInput(), + "--authenticator", + "-A", + click_type=OptionalPrompt(), + prompt="Authentication method", + help="Chosen authenticator, if other than password-based", + ), + private_key_path: str = typer.Option( + EmptyInput(), + "--private-key", + "-k", + click_type=OptionalPrompt(), + prompt="Path to private key file", + help="Path to file containing private key", + ), + **options, +) -> CommandResult: + """Adds a connection to configuration file.""" + if connection_exists(connection_name): + raise ClickException(f"Connection {connection_name} already exists") - @app.command() - def add( - connection_name: str = typer.Option( - None, - "--connection-name", - "-n", - prompt="Name for this connection", - help="Name of the new connection.", - show_default=False, - ), - account: str = typer.Option( - None, - "--account", - "-a", - "--accountname", - prompt="Snowflake account name", - help="Account name to use when authenticating with Snowflake.", - show_default=False, - ), - user: str = typer.Option( - None, - "--user", - "-u", - "--username", - prompt="Snowflake username", - show_default=False, - help="Username to connect to Snowflake.", - ), - password: str = typer.Option( - EmptyInput(), - "--password", - "-p", - click_type=OptionalPrompt(), - callback=_password_callback, - prompt="Snowflake password", - help="Snowflake password.", - hide_input=True, + add_connection( + connection_name, + ConnectionConfig( + account=account, + user=user, + password=password, + host=host, + region=region, + port=port, + database=database, + schema=schema, + warehouse=warehouse, + role=role, + authenticator=authenticator, + private_key_path=private_key_path, ), - role: str = typer.Option( - EmptyInput(), - "--role", - "-r", - click_type=OptionalPrompt(), - prompt="Role for the connection", - help="Role to use on Snowflake.", - ), - warehouse: str = typer.Option( - EmptyInput(), - "--warehouse", - "-w", - click_type=OptionalPrompt(), - prompt="Warehouse for the connection", - help="Warehouse to use on Snowflake.", - ), - database: str = typer.Option( - EmptyInput(), - "--database", - "-d", - click_type=OptionalPrompt(), - prompt="Database for the connection", - help="Database to use on Snowflake.", - ), - schema: str = typer.Option( - EmptyInput(), - "--schema", - "-s", - click_type=OptionalPrompt(), - prompt="Schema for the connection", - help="Schema to use on Snowflake.", - ), - host: str = typer.Option( - EmptyInput(), - "--host", - "-h", - click_type=OptionalPrompt(), - prompt="Connection host", - help="Host name the connection attempts to connect to Snowflake.", - ), - port: int = typer.Option( - EmptyInput(), - "--port", - "-P", - click_type=OptionalPrompt(), - prompt="Connection port", - help="Port to communicate with on the host.", - callback=require_integer(field_name="port"), - ), - region: str = typer.Option( - EmptyInput(), - "--region", - "-R", - click_type=OptionalPrompt(), - prompt="Snowflake region", - help="Region name if not the default Snowflake deployment.", - ), - authenticator: str = typer.Option( - EmptyInput(), - "--authenticator", - "-A", - click_type=OptionalPrompt(), - prompt="Authentication method", - help="Chosen authenticator, if other than password-based", - ), - private_key_path: str = typer.Option( - EmptyInput(), - "--private-key", - "-k", - click_type=OptionalPrompt(), - prompt="Path to private key file", - help="Path to file containing private key", - ), - **options, - ) -> CommandResult: - """Adds a connection to configuration file.""" - if connection_exists(connection_name): - raise ClickException(f"Connection {connection_name} already exists") + ) + return MessageResult( + f"Wrote new connection {connection_name} to {CONFIG_MANAGER.file_path}" + ) - add_connection( - connection_name, - ConnectionConfig( - account=account, - user=user, - password=password, - host=host, - region=region, - port=port, - database=database, - schema=schema, - warehouse=warehouse, - role=role, - authenticator=authenticator, - private_key_path=private_key_path, - ), - ) - return MessageResult( - f"Wrote new connection {connection_name} to {CONFIG_MANAGER.file_path}" - ) - @app.command(requires_connection=True) - def test( - **options, - ) -> CommandResult: - """ - Tests the connection to Snowflake. - """ +@app.command(requires_connection=True) +def test( + **options, +) -> CommandResult: + """ + Tests the connection to Snowflake. + """ - # Test connection - conn = cli_context.connection + # Test connection + conn = cli_context.connection - # Test session attributes - om = ObjectManager() - try: - # "use database" operation changes schema to default "public", - # so to test schema set up by user we need to copy it here: - schema = conn.schema + # Test session attributes + om = ObjectManager() + try: + # "use database" operation changes schema to default "public", + # so to test schema set up by user we need to copy it here: + schema = conn.schema - if conn.role: - om.use(object_type=ObjectType.ROLE, name=f'"{conn.role}"') - if conn.database: - om.use(object_type=ObjectType.DATABASE, name=f'"{conn.database}"') - if schema: - om.use(object_type=ObjectType.SCHEMA, name=f'"{schema}"') - if conn.warehouse: - om.use(object_type=ObjectType.WAREHOUSE, name=f'"{conn.warehouse}"') + if conn.role: + om.use(object_type=ObjectType.ROLE, name=f'"{conn.role}"') + if conn.database: + om.use(object_type=ObjectType.DATABASE, name=f'"{conn.database}"') + if schema: + om.use(object_type=ObjectType.SCHEMA, name=f'"{schema}"') + if conn.warehouse: + om.use(object_type=ObjectType.WAREHOUSE, name=f'"{conn.warehouse}"') - except ProgrammingError as err: - raise ClickException(str(err)) + except ProgrammingError as err: + raise ClickException(str(err)) - conn_ctx = cli_context.connection_context - result = { - "Connection name": conn_ctx.connection_name, - "Status": "OK", - "Host": conn.host, - "Account": conn.account, - "User": conn.user, - "Role": f'{conn.role or "not set"}', - "Database": f'{conn.database or "not set"}', - "Warehouse": f'{conn.warehouse or "not set"}', - } + conn_ctx = cli_context.connection_context + result = { + "Connection name": conn_ctx.connection_name, + "Status": "OK", + "Host": conn.host, + "Account": conn.account, + "User": conn.user, + "Role": f'{conn.role or "not set"}', + "Database": f'{conn.database or "not set"}', + "Warehouse": f'{conn.warehouse or "not set"}', + } - if conn_ctx.enable_diag: - result[ - "Diag Report Location" - ] = f"{conn_ctx.diag_log_path}/SnowflakeConnectionTestReport.txt" + if conn_ctx.enable_diag: + result[ + "Diag Report Location" + ] = f"{conn_ctx.diag_log_path}/SnowflakeConnectionTestReport.txt" - return ObjectResult(result) + return ObjectResult(result) - @app.command(requires_connection=False) - def set_default( - name: str = typer.Argument( - help="Name of the connection, as defined in your `config.toml`" - ), - **options, - ): - """Changes default connection to provided value.""" - get_connection_dict(connection_name=name) - set_config_value(section=None, key="default_connection_name", value=name) - return MessageResult(f"Default connection set to: {name}") - return app +@app.command(requires_connection=False) +def set_default( + name: str = typer.Argument( + help="Name of the connection, as defined in your `config.toml`" + ), + **options, +): + """Changes default connection to provided value.""" + get_connection_dict(connection_name=name) + set_config_value(section=None, key="default_connection_name", value=name) + return MessageResult(f"Default connection set to: {name}") diff --git a/src/snowflake/cli/plugins/connection/plugin_spec.py b/src/snowflake/cli/plugins/connection/plugin_spec.py index e162cd85f8..87221d6f6e 100644 --- a/src/snowflake/cli/plugins/connection/plugin_spec.py +++ b/src/snowflake/cli/plugins/connection/plugin_spec.py @@ -12,5 +12,5 @@ def command_spec(): return CommandSpec( parent_command_path=SNOWCLI_ROOT_COMMAND_PATH, command_type=CommandType.COMMAND_GROUP, - typer_instance=commands.create_app(), + typer_instance=commands.app, ) diff --git a/src/snowflake/cli/plugins/cortex/commands.py b/src/snowflake/cli/plugins/cortex/commands.py index 165d63d08f..4a2ba04ee2 100644 --- a/src/snowflake/cli/plugins/cortex/commands.py +++ b/src/snowflake/cli/plugins/cortex/commands.py @@ -26,303 +26,287 @@ Text, ) +app = SnowTyper( + name="cortex", + help="Provides access to Snowflake Cortex.", +) + SEARCH_COMMAND_ENABLED = sys.version_info < (3, 12) -def create_app(): - app = SnowTyper( - name="cortex", - help="Provides access to Snowflake Cortex.", +@app.command( + requires_connection=True, + hidden=not SEARCH_COMMAND_ENABLED, +) +def search( + query: str = typer.Argument(help="The search query string"), + service: str = typer.Option( + help="Cortex search service to be used. Example: --service my_cortex_service", + ), + columns: Optional[List[str]] = typer.Option( + help='Columns that will be returned with the results. If none is provided, only search column will be included in results. Example --columns "foo" --columns "bar"', + default=None, + ), + limit: int = typer.Option(help="Maximum number of results retrieved", default=1), + **options, +): + """ + Performs query search using Cortex Search Services. + """ + + if not SEARCH_COMMAND_ENABLED: + raise click.ClickException( + "Cortex Search uses Snowflake Python API that currently does not support your Python version" + ) + + from snowflake.core import Root + + if not columns: + columns = [] + + conn = cli_context.connection + + search_service = ( + Root(conn) + .databases[conn.database] + .schemas[conn.schema] + .cortex_search_services[service] ) - @app.command( - requires_connection=True, - hidden=not SEARCH_COMMAND_ENABLED, + response = search_service.search( + query=query, columns=columns, limit=limit, filter={} ) - def search( - query: str = typer.Argument(help="The search query string"), - service: str = typer.Option( - help="Cortex search service to be used. Example: --service my_cortex_service", - ), - columns: Optional[List[str]] = typer.Option( - help='Columns that will be returned with the results. If none is provided, only search column will be included in results. Example --columns "foo" --columns "bar"', - default=None, - ), - limit: int = typer.Option( - help="Maximum number of results retrieved", default=1 - ), - **options, - ): - """ - Performs query search using Cortex Search Services. - """ - - if not SEARCH_COMMAND_ENABLED: - raise click.ClickException( - "Cortex Search uses Snowflake Python API that currently does not support your Python version" - ) - - from snowflake.core import Root - - if not columns: - columns = [] - - conn = cli_context.connection - - search_service = ( - Root(conn) - .databases[conn.database] - .schemas[conn.schema] - .cortex_search_services[service] + + return CollectionResult(response.results) + + +@app.command( + name="complete", + requires_connection=True, +) +def complete( + text: Optional[str] = typer.Argument( + None, + help="Prompt to be used to generate a completion. Cannot be combined with --file option.", + show_default=False, + ), + model: Optional[str] = typer.Option( + DEFAULT_MODEL, + "--model", + help="String specifying the model to be used.", + ), + file: Optional[Path] = readable_file_option( + param_name="--file", + help_str="JSON file containing conversation history to be used to generate a completion. Cannot be combined with TEXT argument.", + ), + **options, +) -> CommandResult: + """ + Given a prompt, the command generates a response using your choice of language model. + In the simplest use case, the prompt is a single string. + You may also provide a JSON file with conversation history including multiple prompts and responses for interactive chat-style usage. + """ + + manager = CortexManager() + + if text and file: + raise UsageError("--file option cannot be used together with TEXT argument.") + if text: + result_text = manager.complete_for_prompt( + text=Text(text), + model=Model(model), + ) + elif file: + result_text = manager.complete_for_conversation( + conversation_json_file=SecurePath(file), + model=Model(model), ) + else: + raise UsageError("Either --file option or TEXT argument has to be provided.") + + return MessageResult(result_text.strip()) - response = search_service.search( - query=query, columns=columns, limit=limit, filter={} + +@app.command( + name="extract-answer", + requires_connection=True, +) +def extract_answer( + question: str = typer.Argument( + None, + help="String containing the question to be answered.", + show_default=False, + ), + source_document_text: Optional[str] = typer.Argument( + None, + help="String containing the plain-text or JSON document that contains the answer to the question. Cannot be combined with --file option.", + show_default=False, + ), + file: Optional[Path] = readable_file_option( + param_name="--file", + help_str="File containing the plain-text or JSON document that contains the answer to the question. Cannot be combined with SOURCE_DOCUMENT_TEXT argument.", + ), + **options, +) -> CommandResult: + """ + Extracts an answer to a given question from a text document. + The document may be a plain-English document or a string representation of a semi-structured (JSON) data object. + """ + + manager = CortexManager() + + if source_document_text and file: + raise UsageError( + "--file option cannot be used together with SOURCE_DOCUMENT_TEXT argument." + ) + if source_document_text: + result_text = manager.extract_answer_from_source_document( + source_document=SourceDocument(source_document_text), + question=Question(question), + ) + elif file: + result_text = manager.extract_answer_from_source_document_file( + source_document_input_file=SecurePath(file), + question=Question(question), + ) + else: + raise UsageError( + "Either --file option or SOURCE_DOCUMENT_TEXT argument has to be provided." ) - return CollectionResult(response.results) + return MessageResult(result_text.strip()) - @app.command( - name="complete", - requires_connection=True, - ) - def complete( - text: Optional[str] = typer.Argument( - None, - help="Prompt to be used to generate a completion. Cannot be combined with --file option.", - show_default=False, - ), - model: Optional[str] = typer.Option( - DEFAULT_MODEL, - "--model", - help="String specifying the model to be used.", - ), - file: Optional[Path] = readable_file_option( - param_name="--file", - help_str="JSON file containing conversation history to be used to generate a completion. Cannot be combined with TEXT argument.", - ), - **options, - ) -> CommandResult: - """ - Given a prompt, the command generates a response using your choice of language model. - In the simplest use case, the prompt is a single string. - You may also provide a JSON file with conversation history including multiple prompts and responses for interactive chat-style usage. - """ - - manager = CortexManager() - - if text and file: - raise UsageError( - "--file option cannot be used together with TEXT argument." - ) - if text: - result_text = manager.complete_for_prompt( - text=Text(text), - model=Model(model), - ) - elif file: - result_text = manager.complete_for_conversation( - conversation_json_file=SecurePath(file), - model=Model(model), - ) - else: - raise UsageError( - "Either --file option or TEXT argument has to be provided." - ) - - return MessageResult(result_text.strip()) - - @app.command( - name="extract-answer", - requires_connection=True, - ) - def extract_answer( - question: str = typer.Argument( - None, - help="String containing the question to be answered.", - show_default=False, - ), - source_document_text: Optional[str] = typer.Argument( - None, - help="String containing the plain-text or JSON document that contains the answer to the question. Cannot be combined with --file option.", - show_default=False, - ), - file: Optional[Path] = readable_file_option( - param_name="--file", - help_str="File containing the plain-text or JSON document that contains the answer to the question. Cannot be combined with SOURCE_DOCUMENT_TEXT argument.", - ), - **options, - ) -> CommandResult: - """ - Extracts an answer to a given question from a text document. - The document may be a plain-English document or a string representation of a semi-structured (JSON) data object. - """ - - manager = CortexManager() - - if source_document_text and file: - raise UsageError( - "--file option cannot be used together with SOURCE_DOCUMENT_TEXT argument." - ) - if source_document_text: - result_text = manager.extract_answer_from_source_document( - source_document=SourceDocument(source_document_text), - question=Question(question), - ) - elif file: - result_text = manager.extract_answer_from_source_document_file( - source_document_input_file=SecurePath(file), - question=Question(question), - ) - else: - raise UsageError( - "Either --file option or SOURCE_DOCUMENT_TEXT argument has to be provided." - ) - - return MessageResult(result_text.strip()) - - @app.command( - name="sentiment", - requires_connection=True, - ) - def sentiment( - text: Optional[str] = typer.Argument( - None, - help="String containing the text for which a sentiment score should be calculated. Cannot be combined with --file option.", - show_default=False, - ), - file: Optional[Path] = readable_file_option( - param_name="--file", - help_str="File containing the text for which a sentiment score should be calculated. Cannot be combined with TEXT argument.", - ), - **options, - ) -> CommandResult: - """ - Returns sentiment as a score between -1 to 1 - (with -1 being the most negative and 1 the most positive, - with values around 0 neutral) for the given English-language input text. - """ - - manager = CortexManager() - - if text and file: - raise UsageError( - "--file option cannot be used together with TEXT argument." - ) - if text: - result_text = manager.calculate_sentiment_for_text( - text=Text(text), - ) - elif file: - result_text = manager.calculate_sentiment_for_text_file( - text_file=SecurePath(file), - ) - else: - raise UsageError( - "Either --file option or TEXT argument has to be provided." - ) - - return MessageResult(result_text.strip()) - - @app.command( - name="summarize", - requires_connection=True, - ) - def summarize( - text: Optional[str] = typer.Argument( - None, - help="String containing the English text from which a summary should be generated. Cannot be combined with --file option.", - show_default=False, - ), - file: Optional[Path] = readable_file_option( - param_name="--file", - help_str="File containing the English text from which a summary should be generated. Cannot be combined with TEXT argument.", - ), - **options, - ) -> CommandResult: - """ - Summarizes the given English-language input text. - """ - - manager = CortexManager() - - if text and file: - raise UsageError( - "--file option cannot be used together with TEXT argument." - ) - if text: - result_text = manager.summarize_text( - text=Text(text), - ) - elif file: - result_text = manager.summarize_text_file( - text_file=SecurePath(file), - ) - else: - raise UsageError( - "Either --file option or TEXT argument has to be provided." - ) - - return MessageResult(result_text.strip()) - - @app.command( - name="translate", - requires_connection=True, - ) - def translate( - text: Optional[str] = typer.Argument( - None, - help="String containing the text to be translated. Cannot be combined with --file option.", - show_default=False, - ), - from_language: Optional[str] = typer.Option( - None, - "--from", - help="String specifying the language code for the language the text is currently in. See Snowflake Cortex documentation for a list of supported language codes.", - show_default=False, - ), - to_language: str = typer.Option( - ..., - "--to", - help="String specifying the language code into which the text should be translated. See Snowflake Cortex documentation for a list of supported language codes.", - show_default=False, - ), - file: Optional[Path] = readable_file_option( - param_name="--file", - help_str="File containing the text to be translated. Cannot be combined with TEXT argument.", - ), - **options, - ) -> CommandResult: - """ - Translates text from the indicated or detected source language to a target language. - """ - - manager = CortexManager() - - source_language = None if from_language is None else Language(from_language) - target_language = Language(to_language) - - if text and file: - raise UsageError( - "--file option cannot be used together with TEXT argument." - ) - if text: - result_text = manager.translate_text( - text=Text(text), - source_language=source_language, - target_language=target_language, - ) - elif file: - result_text = manager.translate_text_file( - text_file=SecurePath(file), - source_language=source_language, - target_language=target_language, - ) - else: - raise UsageError( - "Either --file option or TEXT argument has to be provided." - ) - - return MessageResult(result_text.strip()) - - return app + +@app.command( + name="sentiment", + requires_connection=True, +) +def sentiment( + text: Optional[str] = typer.Argument( + None, + help="String containing the text for which a sentiment score should be calculated. Cannot be combined with --file option.", + show_default=False, + ), + file: Optional[Path] = readable_file_option( + param_name="--file", + help_str="File containing the text for which a sentiment score should be calculated. Cannot be combined with TEXT argument.", + ), + **options, +) -> CommandResult: + """ + Returns sentiment as a score between -1 to 1 + (with -1 being the most negative and 1 the most positive, + with values around 0 neutral) for the given English-language input text. + """ + + manager = CortexManager() + + if text and file: + raise UsageError("--file option cannot be used together with TEXT argument.") + if text: + result_text = manager.calculate_sentiment_for_text( + text=Text(text), + ) + elif file: + result_text = manager.calculate_sentiment_for_text_file( + text_file=SecurePath(file), + ) + else: + raise UsageError("Either --file option or TEXT argument has to be provided.") + + return MessageResult(result_text.strip()) + + +@app.command( + name="summarize", + requires_connection=True, +) +def summarize( + text: Optional[str] = typer.Argument( + None, + help="String containing the English text from which a summary should be generated. Cannot be combined with --file option.", + show_default=False, + ), + file: Optional[Path] = readable_file_option( + param_name="--file", + help_str="File containing the English text from which a summary should be generated. Cannot be combined with TEXT argument.", + ), + **options, +) -> CommandResult: + """ + Summarizes the given English-language input text. + """ + + manager = CortexManager() + + if text and file: + raise UsageError("--file option cannot be used together with TEXT argument.") + if text: + result_text = manager.summarize_text( + text=Text(text), + ) + elif file: + result_text = manager.summarize_text_file( + text_file=SecurePath(file), + ) + else: + raise UsageError("Either --file option or TEXT argument has to be provided.") + + return MessageResult(result_text.strip()) + + +@app.command( + name="translate", + requires_connection=True, +) +def translate( + text: Optional[str] = typer.Argument( + None, + help="String containing the text to be translated. Cannot be combined with --file option.", + show_default=False, + ), + from_language: Optional[str] = typer.Option( + None, + "--from", + help="String specifying the language code for the language the text is currently in. See Snowflake Cortex documentation for a list of supported language codes.", + show_default=False, + ), + to_language: str = typer.Option( + ..., + "--to", + help="String specifying the language code into which the text should be translated. See Snowflake Cortex documentation for a list of supported language codes.", + show_default=False, + ), + file: Optional[Path] = readable_file_option( + param_name="--file", + help_str="File containing the text to be translated. Cannot be combined with TEXT argument.", + ), + **options, +) -> CommandResult: + """ + Translates text from the indicated or detected source language to a target language. + """ + + manager = CortexManager() + + source_language = None if from_language is None else Language(from_language) + target_language = Language(to_language) + + if text and file: + raise UsageError("--file option cannot be used together with TEXT argument.") + if text: + result_text = manager.translate_text( + text=Text(text), + source_language=source_language, + target_language=target_language, + ) + elif file: + result_text = manager.translate_text_file( + text_file=SecurePath(file), + source_language=source_language, + target_language=target_language, + ) + else: + raise UsageError("Either --file option or TEXT argument has to be provided.") + + return MessageResult(result_text.strip()) diff --git a/src/snowflake/cli/plugins/cortex/plugin_spec.py b/src/snowflake/cli/plugins/cortex/plugin_spec.py index 9b88ceaf4c..ce27955d46 100644 --- a/src/snowflake/cli/plugins/cortex/plugin_spec.py +++ b/src/snowflake/cli/plugins/cortex/plugin_spec.py @@ -12,5 +12,5 @@ def command_spec(): return CommandSpec( parent_command_path=SNOWCLI_ROOT_COMMAND_PATH, command_type=CommandType.COMMAND_GROUP, - typer_instance=commands.create_app(), + typer_instance=commands.app, ) diff --git a/src/snowflake/cli/plugins/git/commands.py b/src/snowflake/cli/plugins/git/commands.py index cf387edc69..a1a8853ac8 100644 --- a/src/snowflake/cli/plugins/git/commands.py +++ b/src/snowflake/cli/plugins/git/commands.py @@ -26,6 +26,12 @@ from snowflake.cli.plugins.stage.commands import get from snowflake.cli.plugins.stage.manager import OnErrorType +app = SnowTyper( + name="git", + help="Manages git repositories in Snowflake.", +) +log = logging.getLogger(__name__) + def _repo_path_argument_callback(path): # All repository paths must start with repository scope: @@ -50,228 +56,220 @@ def _repo_path_argument_callback(path): ), callback=_repo_path_argument_callback, ) +add_object_command_aliases( + app=app, + object_type=ObjectType.GIT_REPOSITORY, + name_argument=RepoNameArgument, + like_option=like_option( + help_example='`list --like "my%"` lists all git repositories with name that begin with “my”', + ), + scope_option=scope_option(help_example="`list --in database my_db`"), +) -def create_app(): - app = SnowTyper( - name="git", - help="Manages git repositories in Snowflake.", - ) - log = logging.getLogger(__name__) - - add_object_command_aliases( - app=app, - object_type=ObjectType.GIT_REPOSITORY, - name_argument=RepoNameArgument, - like_option=like_option( - help_example='`list --like "my%"` lists all git repositories with name that begin with “my”', - ), - scope_option=scope_option(help_example="`list --in database my_db`"), - ) +def _assure_repository_does_not_exist(om: ObjectManager, repository_name: str) -> None: + if om.object_exists( + object_type=ObjectType.GIT_REPOSITORY.value.cli_name, name=repository_name + ): + raise ClickException(f"Repository '{repository_name}' already exists") - def _assure_repository_does_not_exist( - om: ObjectManager, repository_name: str - ) -> None: - if om.object_exists( - object_type=ObjectType.GIT_REPOSITORY.value.cli_name, name=repository_name - ): - raise ClickException(f"Repository '{repository_name}' already exists") - - def _validate_origin_url(url: str) -> None: - if not url.startswith("https://"): - raise ClickException("Url address should start with 'https'") - - @app.command("setup", requires_connection=True) - def setup( - repository_name: str = RepoNameArgument, - **options, - ) -> CommandResult: - """ - Sets up a git repository object. - - You will be prompted for: - - * url - address of repository to be used for git clone operation - - * secret - Snowflake secret containing authentication credentials. Not needed if origin repository does not require - authentication for RO operations (clone, fetch) - - * API integration - object allowing Snowflake to interact with git repository. - """ - manager = GitManager() - om = ObjectManager() - _assure_repository_does_not_exist(om, repository_name) - - url = typer.prompt("Origin url") - _validate_origin_url(url) - - secret_needed = typer.confirm("Use secret for authentication?") - should_create_secret = False - secret_name = None - if secret_needed: - secret_name = f"{repository_name}_secret" - secret_name = typer.prompt( - "Secret identifier (will be created if not exists)", default=secret_name - ) - if om.object_exists( - object_type=ObjectType.SECRET.value.cli_name, name=secret_name - ): - cli_console.step(f"Using existing secret '{secret_name}'") - else: - should_create_secret = True - cli_console.step(f"Secret '{secret_name}' will be created") - secret_username = typer.prompt("username") - secret_password = typer.prompt("password/token", hide_input=True) - - api_integration = f"{repository_name}_api_integration" - api_integration = typer.prompt( - "API integration identifier (will be created if not exists)", - default=api_integration, - ) - if should_create_secret: - manager.create_password_secret( - name=secret_name, username=secret_username, password=secret_password - ) - cli_console.step(f"Secret '{secret_name}' successfully created.") +def _validate_origin_url(url: str) -> None: + if not url.startswith("https://"): + raise ClickException("Url address should start with 'https'") - if not om.object_exists( - object_type=ObjectType.INTEGRATION.value.cli_name, name=api_integration - ): - manager.create_api_integration( - name=api_integration, - api_provider="git_https_api", - allowed_prefix=url, - secret=secret_name, - ) - cli_console.step( - f"API integration '{api_integration}' successfully created." - ) - else: - cli_console.step(f"Using existing API integration '{api_integration}'.") - return QueryResult( - manager.create( - repo_name=repository_name, - url=url, - api_integration=api_integration, - secret=secret_name, - ) +@app.command("setup", requires_connection=True) +def setup( + repository_name: str = RepoNameArgument, + **options, +) -> CommandResult: + """ + Sets up a git repository object. + + You will be prompted for: + + * url - address of repository to be used for git clone operation + + * secret - Snowflake secret containing authentication credentials. Not needed if origin repository does not require + authentication for RO operations (clone, fetch) + + * API integration - object allowing Snowflake to interact with git repository. + """ + manager = GitManager() + om = ObjectManager() + _assure_repository_does_not_exist(om, repository_name) + + url = typer.prompt("Origin url") + _validate_origin_url(url) + + secret_needed = typer.confirm("Use secret for authentication?") + should_create_secret = False + secret_name = None + if secret_needed: + secret_name = f"{repository_name}_secret" + secret_name = typer.prompt( + "Secret identifier (will be created if not exists)", default=secret_name ) + if om.object_exists( + object_type=ObjectType.SECRET.value.cli_name, name=secret_name + ): + cli_console.step(f"Using existing secret '{secret_name}'") + else: + should_create_secret = True + cli_console.step(f"Secret '{secret_name}' will be created") + secret_username = typer.prompt("username") + secret_password = typer.prompt("password/token", hide_input=True) - @app.command( - "list-branches", - requires_connection=True, + api_integration = f"{repository_name}_api_integration" + api_integration = typer.prompt( + "API integration identifier (will be created if not exists)", + default=api_integration, ) - def list_branches( - repository_name: str = RepoNameArgument, - like=like_option( - help_example='`list-branches --like "%_test"` lists all branches that end with "_test"' - ), - **options, - ) -> CommandResult: - """ - List all branches in the repository. - """ - return QueryResult( - GitManager().show_branches(repo_name=repository_name, like=like) + + if should_create_secret: + manager.create_password_secret( + name=secret_name, username=secret_username, password=secret_password ) + cli_console.step(f"Secret '{secret_name}' successfully created.") - @app.command( - "list-tags", - requires_connection=True, - ) - def list_tags( - repository_name: str = RepoNameArgument, - like=like_option( - help_example='`list-tags --like "v2.0%"` lists all tags that start with "v2.0"' - ), - **options, - ) -> CommandResult: - """ - List all tags in the repository. - """ - return QueryResult(GitManager().show_tags(repo_name=repository_name, like=like)) - - @app.command( - "list-files", - requires_connection=True, - ) - def list_files( - repository_path: str = RepoPathArgument, - pattern=PatternOption, - **options, - ) -> CommandResult: - """ - List files from given state of git repository. - """ - return QueryResult( - GitManager().list_files(stage_name=repository_path, pattern=pattern) + if not om.object_exists( + object_type=ObjectType.INTEGRATION.value.cli_name, name=api_integration + ): + manager.create_api_integration( + name=api_integration, + api_provider="git_https_api", + allowed_prefix=url, + secret=secret_name, ) + cli_console.step(f"API integration '{api_integration}' successfully created.") + else: + cli_console.step(f"Using existing API integration '{api_integration}'.") - @app.command( - "fetch", - requires_connection=True, + return QueryResult( + manager.create( + repo_name=repository_name, + url=url, + api_integration=api_integration, + secret=secret_name, + ) ) - def fetch( - repository_name: str = RepoNameArgument, - **options, - ) -> CommandResult: - """ - Fetch changes from origin to Snowflake repository. - """ - return QueryResult(GitManager().fetch(repo_name=repository_name)) - - @app.command( - "copy", - requires_connection=True, + + +@app.command( + "list-branches", + requires_connection=True, +) +def list_branches( + repository_name: str = RepoNameArgument, + like=like_option( + help_example='`list-branches --like "%_test"` lists all branches that end with "_test"' + ), + **options, +) -> CommandResult: + """ + List all branches in the repository. + """ + return QueryResult(GitManager().show_branches(repo_name=repository_name, like=like)) + + +@app.command( + "list-tags", + requires_connection=True, +) +def list_tags( + repository_name: str = RepoNameArgument, + like=like_option( + help_example='`list-tags --like "v2.0%"` lists all tags that start with "v2.0"' + ), + **options, +) -> CommandResult: + """ + List all tags in the repository. + """ + return QueryResult(GitManager().show_tags(repo_name=repository_name, like=like)) + + +@app.command( + "list-files", + requires_connection=True, +) +def list_files( + repository_path: str = RepoPathArgument, + pattern=PatternOption, + **options, +) -> CommandResult: + """ + List files from given state of git repository. + """ + return QueryResult( + GitManager().list_files(stage_name=repository_path, pattern=pattern) ) - def copy( - repository_path: str = RepoPathArgument, - destination_path: str = typer.Argument( - help="Target path for copy operation. Should be a path to a directory on remote stage or local file system.", - ), - parallel: int = typer.Option( - 4, - help="Number of parallel threads to use when downloading files.", - ), - **options, - ): - """ - Copies all files from given state of repository to local directory or stage. - - If the source path ends with '/', the command copies contents of specified directory. - Otherwise, it creates a new directory or file in the destination directory. - """ - is_copy = is_stage_path(destination_path) - if is_copy: - return QueryResult( - GitManager().copy_files( - source_path=repository_path, destination_path=destination_path - ) + + +@app.command( + "fetch", + requires_connection=True, +) +def fetch( + repository_name: str = RepoNameArgument, + **options, +) -> CommandResult: + """ + Fetch changes from origin to Snowflake repository. + """ + return QueryResult(GitManager().fetch(repo_name=repository_name)) + + +@app.command( + "copy", + requires_connection=True, +) +def copy( + repository_path: str = RepoPathArgument, + destination_path: str = typer.Argument( + help="Target path for copy operation. Should be a path to a directory on remote stage or local file system.", + ), + parallel: int = typer.Option( + 4, + help="Number of parallel threads to use when downloading files.", + ), + **options, +): + """ + Copies all files from given state of repository to local directory or stage. + + If the source path ends with '/', the command copies contents of specified directory. + Otherwise, it creates a new directory or file in the destination directory. + """ + is_copy = is_stage_path(destination_path) + if is_copy: + return QueryResult( + GitManager().copy_files( + source_path=repository_path, destination_path=destination_path ) - return get( - recursive=True, - source_path=repository_path, - destination_path=destination_path, - parallel=parallel, ) + return get( + recursive=True, + source_path=repository_path, + destination_path=destination_path, + parallel=parallel, + ) - @app.command("execute", requires_connection=True) - def execute( - repository_path: str = RepoPathArgument, - on_error: OnErrorType = OnErrorOption, - variables: Optional[List[str]] = VariablesOption, - **options, - ): - """ - Execute immediate all files from the repository path. Files can be filtered with glob like pattern, - e.g. `@my_repo/branches/main/*.sql`, `@my_repo/branches/main/dev/*`. Only files with `.sql` - extension will be executed. - """ - results = GitManager().execute( - stage_path=repository_path, on_error=on_error, variables=variables - ) - return CollectionResult(results) - return app +@app.command("execute", requires_connection=True) +def execute( + repository_path: str = RepoPathArgument, + on_error: OnErrorType = OnErrorOption, + variables: Optional[List[str]] = VariablesOption, + **options, +): + """ + Execute immediate all files from the repository path. Files can be filtered with glob like pattern, + e.g. `@my_repo/branches/main/*.sql`, `@my_repo/branches/main/dev/*`. Only files with `.sql` + extension will be executed. + """ + results = GitManager().execute( + stage_path=repository_path, on_error=on_error, variables=variables + ) + return CollectionResult(results) diff --git a/src/snowflake/cli/plugins/git/plugin_spec.py b/src/snowflake/cli/plugins/git/plugin_spec.py index 1db86b6365..f5aead9108 100644 --- a/src/snowflake/cli/plugins/git/plugin_spec.py +++ b/src/snowflake/cli/plugins/git/plugin_spec.py @@ -12,5 +12,5 @@ def command_spec(): return CommandSpec( parent_command_path=SNOWCLI_ROOT_COMMAND_PATH, command_type=CommandType.COMMAND_GROUP, - typer_instance=commands.create_app(), + typer_instance=commands.app, ) diff --git a/src/snowflake/cli/plugins/notebook/commands.py b/src/snowflake/cli/plugins/notebook/commands.py index 661003ed74..f8647ef0d7 100644 --- a/src/snowflake/cli/plugins/notebook/commands.py +++ b/src/snowflake/cli/plugins/notebook/commands.py @@ -9,10 +9,14 @@ from snowflake.cli.plugins.notebook.types import NotebookName, NotebookStagePath from typing_extensions import Annotated +app = SnowTyper( + name="notebook", + help="Manages notebooks in Snowflake.", + hidden=FeatureFlag.ENABLE_NOTEBOOKS.is_disabled(), +) log = logging.getLogger(__name__) NOTEBOOK_IDENTIFIER = identifier_argument(sf_object="notebook", example="MY_NOTEBOOK") - NotebookFile: NotebookStagePath = typer.Option( "--notebook-file", "-f", @@ -20,55 +24,49 @@ ) -def create_app(): - app = SnowTyper( - name="notebook", - help="Manages notebooks in Snowflake.", - hidden=FeatureFlag.ENABLE_NOTEBOOKS.is_disabled(), - ) +@app.command(requires_connection=True) +def execute( + identifier: str = NOTEBOOK_IDENTIFIER, + **options, +): + """ + Executes a notebook in a headless mode. + """ + # Execution does not return any meaningful result + _ = NotebookManager().execute(notebook_name=identifier) + return MessageResult(f"Notebook {identifier} executed.") - @app.command(requires_connection=True) - def execute( - identifier: str = NOTEBOOK_IDENTIFIER, - **options, - ): - """ - Executes a notebook in a headless mode. - """ - # Execution does not return any meaningful result - _ = NotebookManager().execute(notebook_name=identifier) - return MessageResult(f"Notebook {identifier} executed.") - @app.command(requires_connection=True) - def get_url( - identifier: str = NOTEBOOK_IDENTIFIER, - **options, - ): - """Return a url to a notebook.""" - url = NotebookManager().get_url(notebook_name=identifier) - return MessageResult(message=url) +@app.command(requires_connection=True) +def get_url( + identifier: str = NOTEBOOK_IDENTIFIER, + **options, +): + """Return a url to a notebook.""" + url = NotebookManager().get_url(notebook_name=identifier) + return MessageResult(message=url) - @app.command(name="open", requires_connection=True) - def open_cmd( - identifier: str = NOTEBOOK_IDENTIFIER, - **options, - ): - """Opens a notebook in default browser""" - url = NotebookManager().get_url(notebook_name=identifier) - typer.launch(url) - return MessageResult(message=url) - @app.command(requires_connection=True) - def create( - identifier: Annotated[NotebookName, NOTEBOOK_IDENTIFIER], - notebook_file: Annotated[NotebookStagePath, NotebookFile], - **options, - ): - """Creates notebook from stage.""" - notebook_url = NotebookManager().create( - notebook_name=identifier, - notebook_file=notebook_file, - ) - return MessageResult(message=notebook_url) +@app.command(name="open", requires_connection=True) +def open_cmd( + identifier: str = NOTEBOOK_IDENTIFIER, + **options, +): + """Opens a notebook in default browser""" + url = NotebookManager().get_url(notebook_name=identifier) + typer.launch(url) + return MessageResult(message=url) - return app + +@app.command(requires_connection=True) +def create( + identifier: Annotated[NotebookName, NOTEBOOK_IDENTIFIER], + notebook_file: Annotated[NotebookStagePath, NotebookFile], + **options, +): + """Creates notebook from stage.""" + notebook_url = NotebookManager().create( + notebook_name=identifier, + notebook_file=notebook_file, + ) + return MessageResult(message=notebook_url) diff --git a/src/snowflake/cli/plugins/notebook/plugin_spec.py b/src/snowflake/cli/plugins/notebook/plugin_spec.py index b875383f59..edcaef7568 100644 --- a/src/snowflake/cli/plugins/notebook/plugin_spec.py +++ b/src/snowflake/cli/plugins/notebook/plugin_spec.py @@ -12,5 +12,5 @@ def command_spec(): return CommandSpec( parent_command_path=SNOWCLI_ROOT_COMMAND_PATH, command_type=CommandType.COMMAND_GROUP, - typer_instance=commands.create_app(), + typer_instance=commands.app, ) diff --git a/src/snowflake/cli/plugins/object/__init__.py b/src/snowflake/cli/plugins/object/__init__.py index e69de29bb2..cf5630e028 100644 --- a/src/snowflake/cli/plugins/object/__init__.py +++ b/src/snowflake/cli/plugins/object/__init__.py @@ -0,0 +1,11 @@ +from snowflake.cli.api.commands.snow_typer import SnowTyper +from snowflake.cli.plugins.object.commands import app as show_app +from snowflake.cli.plugins.stage.commands import app as stage_app + +app = SnowTyper( + name="object", + help="Manages Snowflake objects like warehouses and stages", +) + +app.add_typer(stage_app) # type: ignore +app.add_typer(show_app) # type: ignore diff --git a/src/snowflake/cli/plugins/object/command_aliases.py b/src/snowflake/cli/plugins/object/command_aliases.py index 4e50ee1f2d..d4db7de20e 100644 --- a/src/snowflake/cli/plugins/object/command_aliases.py +++ b/src/snowflake/cli/plugins/object/command_aliases.py @@ -31,7 +31,7 @@ def add_object_command_aliases( @app.command("list", requires_connection=True) def list_cmd(like: str = like_option, **options): # type: ignore - return list_( + list_( object_type=object_type.value.cli_name, like=like, scope=ScopeOption.default, @@ -46,7 +46,7 @@ def list_cmd( scope: Tuple[str, str] = scope_option, # type: ignore **options, ): - return list_( + list_( object_type=object_type.value.cli_name, like=like, scope=scope, @@ -59,7 +59,7 @@ def list_cmd( @app.command("drop", requires_connection=True) def drop_cmd(name: str = name_argument, **options): - return drop( + drop( object_type=object_type.value.cli_name, object_name=name, **options, @@ -71,7 +71,7 @@ def drop_cmd(name: str = name_argument, **options): @app.command("describe", requires_connection=True) def describe_cmd(name: str = name_argument, **options): - return describe( + describe( object_type=object_type.value.cli_name, object_name=name, **options, diff --git a/src/snowflake/cli/plugins/object/commands.py b/src/snowflake/cli/plugins/object/commands.py index 41c6d8c182..1ceb30c509 100644 --- a/src/snowflake/cli/plugins/object/commands.py +++ b/src/snowflake/cli/plugins/object/commands.py @@ -11,6 +11,12 @@ from snowflake.cli.api.project.util import is_valid_identifier from snowflake.cli.plugins.object.manager import ObjectManager +app = SnowTyper( + name="object", + help="Manages Snowflake objects like warehouses and stages", +) + + NameArgument = typer.Argument(help="Name of the object") ObjectArgument = typer.Argument( help="Type of object. For example table, procedure, streamlit.", @@ -46,14 +52,16 @@ def scope_option(help_example: str): SUPPORTED_TYPES_MSG = "\n\nSupported types: " + ", ".join(SUPPORTED_OBJECTS) -# Image repository is the only supported object that does not have a DESCRIBE command. -DESCRIBE_SUPPORTED_TYPES_MSG = f"\n\nSupported types: {', '.join(obj for obj in SUPPORTED_OBJECTS if obj != 'image-repository')}" - +@app.command( + "list", + help=f"Lists all available Snowflake objects of given type.{SUPPORTED_TYPES_MSG}", + requires_connection=True, +) def list_( - object_type: str, - like: str, - scope: Tuple[str, str], + object_type: str = ObjectArgument, + like: str = LikeOption, + scope: Tuple[str, str] = ScopeOption, **options, ): _scope_validate(object_type, scope) @@ -62,53 +70,25 @@ def list_( ) -def drop(object_type: str, object_name: str, **options): +@app.command( + help=f"Drops Snowflake object of given name and type. {SUPPORTED_TYPES_MSG}", + requires_connection=True, +) +def drop(object_type: str = ObjectArgument, object_name: str = NameArgument, **options): return QueryResult(ObjectManager().drop(object_type=object_type, name=object_name)) -def describe(object_type: str, object_name: str, **options): - return QueryResult( - ObjectManager().describe(object_type=object_type, name=object_name) - ) - +# Image repository is the only supported object that does not have a DESCRIBE command. +DESCRIBE_SUPPORTED_TYPES_MSG = f"\n\nSupported types: {', '.join(obj for obj in SUPPORTED_OBJECTS if obj != 'image-repository')}" -def create_app(): - app = SnowTyper( - name="object", - help="Manages Snowflake objects like warehouses and stages", - ) - @app.command( - "list", - help=f"Lists all available Snowflake objects of given type.{SUPPORTED_TYPES_MSG}", - requires_connection=True, - ) - def list_cmd( - object_type: str = ObjectArgument, - like: str = LikeOption, - scope: Tuple[str, str] = ScopeOption, - **options, - ): - return list_(object_type=object_type, like=like, scope=scope, **options) - - @app.command( - "drop", - help=f"Drops Snowflake object of given name and type. {SUPPORTED_TYPES_MSG}", - requires_connection=True, - ) - def drop_cmd( - object_type: str = ObjectArgument, object_name: str = NameArgument, **options - ): - return drop(object_type=object_type, object_name=object_name, **options) - - @app.command( - "describe", - help=f"Provides description of an object of given type. {DESCRIBE_SUPPORTED_TYPES_MSG}", - requires_connection=True, +@app.command( + help=f"Provides description of an object of given type. {DESCRIBE_SUPPORTED_TYPES_MSG}", + requires_connection=True, +) +def describe( + object_type: str = ObjectArgument, object_name: str = NameArgument, **options +): + return QueryResult( + ObjectManager().describe(object_type=object_type, name=object_name) ) - def describe_cmd( - object_type: str = ObjectArgument, object_name: str = NameArgument, **options - ): - return describe(object_type=object_type, object_name=object_name, **options) - - return app diff --git a/src/snowflake/cli/plugins/object/plugin_spec.py b/src/snowflake/cli/plugins/object/plugin_spec.py index 4f2057d83c..8681ee949b 100644 --- a/src/snowflake/cli/plugins/object/plugin_spec.py +++ b/src/snowflake/cli/plugins/object/plugin_spec.py @@ -4,7 +4,7 @@ CommandType, plugin_hook_impl, ) -from snowflake.cli.plugins.object import commands +from snowflake.cli.plugins.object.commands import app as object_app @plugin_hook_impl @@ -12,5 +12,5 @@ def command_spec(): return CommandSpec( parent_command_path=SNOWCLI_ROOT_COMMAND_PATH, command_type=CommandType.COMMAND_GROUP, - typer_instance=commands.create_app(), + typer_instance=object_app, ) diff --git a/src/snowflake/cli/plugins/object_stage_deprecated/commands.py b/src/snowflake/cli/plugins/object_stage_deprecated/commands.py index 108793876b..351c8fffb2 100644 --- a/src/snowflake/cli/plugins/object_stage_deprecated/commands.py +++ b/src/snowflake/cli/plugins/object_stage_deprecated/commands.py @@ -23,84 +23,86 @@ f" Please use `{CommandPath(['stage'])}` instead." ) +app = SnowTyper(name="stage", help="Manages stages.", deprecated=True) -def create_app(): - app = SnowTyper(name="stage", help="Manages stages.", deprecated=True) - - @app.callback() - def warn_command_deprecated() -> None: - cli_console.warning(_deprecated_command_msg) - - @app.command("list", requires_connection=True, deprecated=True) - def deprecated_stage_list( - stage_name: str = StageNameArgument, pattern=PatternOption, **options - ): - """ - Lists the stage contents. - """ - return stage_list_files(stage_name=stage_name, pattern=pattern, **options) - - @app.command("copy", requires_connection=True, deprecated=True) - def deprecated_copy( - source_path: str = typer.Argument( - help="Source path for copy operation. Can be either stage path or local." - ), - destination_path: str = typer.Argument( - help="Target directory path for copy operation. Should be stage if source is local or local if source is stage.", - ), - overwrite: bool = typer.Option( - False, - help="Overwrites existing files in the target path.", - ), - parallel: int = typer.Option( - 4, - help="Number of parallel threads to use when uploading files.", - ), - recursive: bool = typer.Option( - False, - help="Copy files recursively with directory structure.", - ), - **options, - ): - """ - Copies all files from target path to target directory. This works for both uploading - to and downloading files from the stage. - """ - copy( - source_path=source_path, - destination_path=destination_path, - overwrite=overwrite, - parallel=parallel, - recursive=recursive, - ) - - @app.command("create", requires_connection=True, deprecated=True) - def deprecated_stage_create(stage_name: str = StageNameArgument, **options): - """ - Creates a named stage if it does not already exist. - """ - stage_create(stage_name=stage_name, **options) - - @app.command("remove", requires_connection=True, deprecated=True) - def deprecated_stage_remove( - stage_name: str = StageNameArgument, - file_name: str = typer.Argument(..., help="Name of the file to remove."), - **options, - ): - """ - Removes a file from a stage. - """ - stage_remove(stage_name=stage_name, file_name=file_name) - - @app.command("diff", hidden=True, requires_connection=True, deprecated=True) - def deprecated_stage_diff( - stage_name: str = typer.Argument(None, help="Fully qualified name of a stage"), - folder_name: str = typer.Argument(None, help="Path to local folder"), - **options, - ): - """ - Diffs a stage with a local folder. - """ - return stage_diff(stage_name=stage_name, folder_name=folder_name, **options) - - return app + +@app.callback() +def warn_command_deprecated() -> None: + cli_console.warning(_deprecated_command_msg) + + +@app.command("list", requires_connection=True, deprecated=True) +def deprecated_stage_list( + stage_name: str = StageNameArgument, pattern=PatternOption, **options +): + """ + Lists the stage contents. + """ + return stage_list_files(stage_name=stage_name, pattern=pattern, **options) + + +@app.command("copy", requires_connection=True, deprecated=True) +def deprecated_copy( + source_path: str = typer.Argument( + help="Source path for copy operation. Can be either stage path or local." + ), + destination_path: str = typer.Argument( + help="Target directory path for copy operation. Should be stage if source is local or local if source is stage.", + ), + overwrite: bool = typer.Option( + False, + help="Overwrites existing files in the target path.", + ), + parallel: int = typer.Option( + 4, + help="Number of parallel threads to use when uploading files.", + ), + recursive: bool = typer.Option( + False, + help="Copy files recursively with directory structure.", + ), + **options, +): + """ + Copies all files from target path to target directory. This works for both uploading + to and downloading files from the stage. + """ + copy( + source_path=source_path, + destination_path=destination_path, + overwrite=overwrite, + parallel=parallel, + recursive=recursive, + ) + + +@app.command("create", requires_connection=True, deprecated=True) +def deprecated_stage_create(stage_name: str = StageNameArgument, **options): + """ + Creates a named stage if it does not already exist. + """ + stage_create(stage_name=stage_name, **options) + + +@app.command("remove", requires_connection=True, deprecated=True) +def deprecated_stage_remove( + stage_name: str = StageNameArgument, + file_name: str = typer.Argument(..., help="Name of the file to remove."), + **options, +): + """ + Removes a file from a stage. + """ + stage_remove(stage_name=stage_name, file_name=file_name) + + +@app.command("diff", hidden=True, requires_connection=True, deprecated=True) +def deprecated_stage_diff( + stage_name: str = typer.Argument(None, help="Fully qualified name of a stage"), + folder_name: str = typer.Argument(None, help="Path to local folder"), + **options, +): + """ + Diffs a stage with a local folder. + """ + return stage_diff(stage_name=stage_name, folder_name=folder_name, **options) diff --git a/src/snowflake/cli/plugins/object_stage_deprecated/plugin_spec.py b/src/snowflake/cli/plugins/object_stage_deprecated/plugin_spec.py index 77384a3359..da1f8a6c72 100644 --- a/src/snowflake/cli/plugins/object_stage_deprecated/plugin_spec.py +++ b/src/snowflake/cli/plugins/object_stage_deprecated/plugin_spec.py @@ -6,7 +6,9 @@ CommandType, plugin_hook_impl, ) -from snowflake.cli.plugins.object_stage_deprecated import commands +from snowflake.cli.plugins.object_stage_deprecated.commands import ( + app as stage_deprecated_app, +) @plugin_hook_impl @@ -14,5 +16,5 @@ def command_spec(): return CommandSpec( parent_command_path=CommandPath(["object"]), command_type=CommandType.COMMAND_GROUP, - typer_instance=commands.create_app(), + typer_instance=stage_deprecated_app, ) diff --git a/src/snowflake/cli/plugins/snowpark/__init__.py b/src/snowflake/cli/plugins/snowpark/__init__.py index e69de29bb2..83b16cf800 100644 --- a/src/snowflake/cli/plugins/snowpark/__init__.py +++ b/src/snowflake/cli/plugins/snowpark/__init__.py @@ -0,0 +1,4 @@ +from snowflake.cli.plugins.snowpark.commands import app +from snowflake.cli.plugins.snowpark.package.commands import app as package_app + +app.add_typer(package_app) diff --git a/src/snowflake/cli/plugins/snowpark/commands.py b/src/snowflake/cli/plugins/snowpark/commands.py index ba87675488..80819b0446 100644 --- a/src/snowflake/cli/plugins/snowpark/commands.py +++ b/src/snowflake/cli/plugins/snowpark/commands.py @@ -59,7 +59,6 @@ ) from snowflake.cli.plugins.snowpark.manager import FunctionManager, ProcedureManager from snowflake.cli.plugins.snowpark.models import YesNoAsk -from snowflake.cli.plugins.snowpark.package import commands as package_commands from snowflake.cli.plugins.snowpark.package.anaconda_packages import ( AnacondaPackages, AnacondaPackagesManager, @@ -78,6 +77,13 @@ from snowflake.cli.plugins.stage.manager import StageManager from snowflake.connector import DictCursor, ProgrammingError +log = logging.getLogger(__name__) + +app = SnowTyper( + name="snowpark", + help="Manages procedures and functions.", +) + ObjectTypeArgument = typer.Argument( help="Type of Snowpark object", case_sensitive=False, @@ -90,13 +96,148 @@ LikeOption = like_option( help_example='`list function --like "my%"` lists all functions that begin with “my”', ) +add_init_command(app, project_type="Snowpark", template="default_snowpark") + + +@app.command("deploy", requires_connection=True) +@with_project_definition("snowpark") +def deploy( + replace: bool = ReplaceOption( + help="Replaces procedure or function, even if no detected changes to metadata" + ), + **options, +) -> CommandResult: + """ + Deploys procedures and functions defined in project. Deploying the project alters all objects defined in it. + By default, if any of the objects exist already the commands will fail unless `--replace` flag is provided. + All deployed objects use the same artifact which is deployed only once. + """ + snowpark = cli_context.project_definition + paths = SnowparkPackagePaths.for_snowpark_project( + project_root=SecurePath(cli_context.project_root), + snowpark_project_definition=snowpark, + ) + procedures = snowpark.procedures + functions = snowpark.functions -class _SnowparkObject(Enum): - """This clas is used only for Snowpark execute where choice is limited.""" + if not procedures and not functions: + raise ClickException( + "No procedures or functions were specified in the project definition." + ) - PROCEDURE = str(ObjectType.PROCEDURE) - FUNCTION = str(ObjectType.FUNCTION) + if not paths.artifact_file.exists(): + raise ClickException( + "Artifact required for deploying the project does not exist in this directory. " + "Please use build command to create it." + ) + + pm = ProcedureManager() + fm = FunctionManager() + om = ObjectManager() + + _assert_object_definitions_are_correct("function", functions) + _assert_object_definitions_are_correct("procedure", procedures) + _check_if_all_defined_integrations_exists(om, functions, procedures) + + existing_functions = _find_existing_objects(ObjectType.FUNCTION, functions, om) + existing_procedures = _find_existing_objects(ObjectType.PROCEDURE, procedures, om) + + if (existing_functions or existing_procedures) and not replace: + msg = "Following objects already exists. Consider using --replace.\n" + msg += "\n".join(f"function: {n}" for n in existing_functions) + msg += "\n" if existing_functions and existing_procedures else "" + msg += "\n".join(f"procedure: {n}" for n in existing_procedures) + raise ClickException(msg) + + # Create stage + stage_name = snowpark.stage_name + stage_manager = StageManager() + stage_name = FQN.from_string(stage_name).using_context() + stage_manager.create( + stage_name=stage_name, comment="deployments managed by Snowflake CLI" + ) + + snowflake_dependencies = _read_snowflake_requrements_file( + paths.snowflake_requirements_file + ) + + artifact_stage_directory = get_app_stage_path(stage_name, snowpark.project_name) + artifact_stage_target = ( + f"{artifact_stage_directory}/{paths.artifact_file.path.name}" + ) + + stage_manager.put( + local_path=paths.artifact_file.path, + stage_path=artifact_stage_directory, + overwrite=True, + ) + + deploy_status = [] + # Procedures + for procedure in procedures: + operation_result = _deploy_single_object( + manager=pm, + object_type=ObjectType.PROCEDURE, + object_definition=procedure, + existing_objects=existing_procedures, + snowflake_dependencies=snowflake_dependencies, + stage_artifact_path=artifact_stage_target, + ) + deploy_status.append(operation_result) + + # Functions + for function in functions: + operation_result = _deploy_single_object( + manager=fm, + object_type=ObjectType.FUNCTION, + object_definition=function, + existing_objects=existing_functions, + snowflake_dependencies=snowflake_dependencies, + stage_artifact_path=artifact_stage_target, + ) + deploy_status.append(operation_result) + + return CollectionResult(deploy_status) + + +def _assert_object_definitions_are_correct( + object_type, object_definitions: List[FunctionSchema | ProcedureSchema] +): + for definition in object_definitions: + database = definition.database + schema = definition.schema_name + name = definition.name + fqn_parts = len(name.split(".")) + if fqn_parts == 3 and database: + raise ClickException( + f"database of {object_type} {name} is redefined in its name" + ) + if fqn_parts >= 2 and schema: + raise ClickException( + f"schema of {object_type} {name} is redefined in its name" + ) + + +def _find_existing_objects( + object_type: ObjectType, + objects: List[Dict], + om: ObjectManager, +): + existing_objects = {} + for object_definition in objects: + identifier = build_udf_sproc_identifier( + object_definition, om, include_parameter_names=False + ) + try: + current_state = om.describe( + object_type=object_type.value.sf_name, + name=identifier, + ) + existing_objects[identifier] = current_state + except ProgrammingError: + pass + return existing_objects def _check_if_all_defined_integrations_exists( @@ -128,398 +269,253 @@ def _check_if_all_defined_integrations_exists( ) -def create_app(): - log = logging.getLogger(__name__) +def get_app_stage_path(stage_name: Optional[str], project_name: str) -> str: + artifact_stage_directory = f"@{(stage_name or DEPLOYMENT_STAGE)}/{project_name}" + return artifact_stage_directory - app = SnowTyper( - name="snowpark", - help="Manages procedures and functions.", - ) - app.add_typer(package_commands.create_app()) - - add_init_command(app, project_type="Snowpark", template="default_snowpark") - - @app.command("deploy", requires_connection=True) - @with_project_definition("snowpark") - def deploy( - replace: bool = ReplaceOption( - help="Replaces procedure or function, even if no detected changes to metadata" - ), - **options, - ) -> CommandResult: - """ - Deploys procedures and functions defined in project. Deploying the project alters all objects defined in it. - By default, if any of the objects exist already the commands will fail unless `--replace` flag is provided. - All deployed objects use the same artifact which is deployed only once. - """ - snowpark = cli_context.project_definition - paths = SnowparkPackagePaths.for_snowpark_project( - project_root=SecurePath(cli_context.project_root), - snowpark_project_definition=snowpark, - ) - procedures = snowpark.procedures - functions = snowpark.functions +def _deploy_single_object( + manager: FunctionManager | ProcedureManager, + object_type: ObjectType, + object_definition: FunctionSchema | ProcedureSchema, + existing_objects: Dict[str, Dict], + snowflake_dependencies: List[str], + stage_artifact_path: str, +): + identifier = build_udf_sproc_identifier( + object_definition, manager, include_parameter_names=False + ) + identifier_with_default_values = build_udf_sproc_identifier( + object_definition, + manager, + include_parameter_names=True, + include_default_values=True, + ) + log.info("Deploying %s: %s", object_type, identifier_with_default_values) - if not procedures and not functions: - raise ClickException( - "No procedures or functions were specified in the project definition." - ) + handler = object_definition.handler + returns = object_definition.returns + imports = object_definition.imports + external_access_integrations = object_definition.external_access_integrations + replace_object = False - if not paths.artifact_file.exists(): - raise ClickException( - "Artifact required for deploying the project does not exist in this directory. " - "Please use build command to create it." - ) + object_exists = identifier in existing_objects + if object_exists: + replace_object = check_if_replace_is_required( + object_type=object_type, + current_state=existing_objects[identifier], + handler=handler, + return_type=returns, + snowflake_dependencies=snowflake_dependencies, + external_access_integrations=external_access_integrations, + imports=imports, + stage_artifact_file=stage_artifact_path, + ) - pm = ProcedureManager() - fm = FunctionManager() - om = ObjectManager() + if object_exists and not replace_object: + return { + "object": identifier_with_default_values, + "type": str(object_type), + "status": "packages updated", + } - _assert_object_definitions_are_correct("function", functions) - _assert_object_definitions_are_correct("procedure", procedures) - _check_if_all_defined_integrations_exists(om, functions, procedures) + create_or_replace_kwargs = { + "identifier": identifier_with_default_values, + "handler": handler, + "return_type": returns, + "artifact_file": stage_artifact_path, + "packages": snowflake_dependencies, + "runtime": object_definition.runtime, + "external_access_integrations": object_definition.external_access_integrations, + "secrets": object_definition.secrets, + "imports": imports, + } + if object_type == ObjectType.PROCEDURE: + create_or_replace_kwargs[ + "execute_as_caller" + ] = object_definition.execute_as_caller + manager.create_or_replace(**create_or_replace_kwargs) + + status = "created" if not object_exists else "definition updated" + return { + "object": identifier_with_default_values, + "type": str(object_type), + "status": status, + } - existing_functions = _find_existing_objects(ObjectType.FUNCTION, functions, om) - existing_procedures = _find_existing_objects( - ObjectType.PROCEDURE, procedures, om - ) - if (existing_functions or existing_procedures) and not replace: - msg = "Following objects already exists. Consider using --replace.\n" - msg += "\n".join(f"function: {n}" for n in existing_functions) - msg += "\n" if existing_functions and existing_procedures else "" - msg += "\n".join(f"procedure: {n}" for n in existing_procedures) - raise ClickException(msg) - - # Create stage - stage_name = snowpark.stage_name - stage_manager = StageManager() - stage_name = FQN.from_string(stage_name).using_context() - stage_manager.create( - stage_name=stage_name, comment="deployments managed by Snowflake CLI" - ) +deprecated_pypi_download_option = typer.Option( + YesNoAsk.NO.value, + "--pypi-download", + help="Whether to download non-Anaconda packages from PyPi.", + hidden=True, + callback=deprecated_flag_callback_enum( + "--pypi-download flag is deprecated. Snowpark build command" + " always tries to download non-Anaconda packages from external index (PyPi by default)." + ), +) - snowflake_dependencies = _read_snowflake_requrements_file( - paths.snowflake_requirements_file - ) - artifact_stage_directory = get_app_stage_path(stage_name, snowpark.project_name) - artifact_stage_target = ( - f"{artifact_stage_directory}/{paths.artifact_file.path.name}" - ) +def _read_snowflake_requrements_file(file_path: SecurePath): + if not file_path.exists(): + return [] + return file_path.read_text(file_size_limit_mb=DEFAULT_SIZE_LIMIT_MB).splitlines() + + +@app.command("build", requires_connection=True) +@with_project_definition("snowpark") +def build( + ignore_anaconda: bool = IgnoreAnacondaOption, + allow_shared_libraries: bool = AllowSharedLibrariesOption, + index_url: Optional[str] = IndexUrlOption, + skip_version_check: bool = SkipVersionCheckOption, + deprecated_package_native_libraries: YesNoAsk = deprecated_allow_native_libraries_option( + "--package-native-libraries" + ), + deprecated_check_anaconda_for_pypi_deps: bool = DeprecatedCheckAnacondaForPyPiDependencies, + _deprecated_pypi_download: YesNoAsk = deprecated_pypi_download_option, + **options, +) -> CommandResult: + """ + Builds the Snowpark project as a `.zip` archive that can be used by `deploy` command. + The archive is built using only the `src` directory specified in the project file. + """ + if not deprecated_check_anaconda_for_pypi_deps: + ignore_anaconda = True + snowpark_paths = SnowparkPackagePaths.for_snowpark_project( + project_root=SecurePath(cli_context.project_root), + snowpark_project_definition=cli_context.project_definition, + ) + log.info("Building package using sources from: %s", snowpark_paths.source.path) - stage_manager.put( - local_path=paths.artifact_file.path, - stage_path=artifact_stage_directory, - overwrite=True, - ) + anaconda_packages_manager = AnacondaPackagesManager() - deploy_status = [] - # Procedures - for procedure in procedures: - operation_result = _deploy_single_object( - manager=pm, - object_type=ObjectType.PROCEDURE, - object_definition=procedure, - existing_objects=existing_procedures, - snowflake_dependencies=snowflake_dependencies, - stage_artifact_path=artifact_stage_target, + with SecurePath.temporary_directory() as packages_dir: + if snowpark_paths.defined_requirements_file.exists(): + log.info("Resolving any requirements from requirements.txt...") + requirements = package_utils.parse_requirements( + requirements_file=snowpark_paths.defined_requirements_file, ) - deploy_status.append(operation_result) - - # Functions - for function in functions: - operation_result = _deploy_single_object( - manager=fm, - object_type=ObjectType.FUNCTION, - object_definition=function, - existing_objects=existing_functions, - snowflake_dependencies=snowflake_dependencies, - stage_artifact_path=artifact_stage_target, + anaconda_packages = ( + AnacondaPackages.empty() + if ignore_anaconda + else anaconda_packages_manager.find_packages_available_in_snowflake_anaconda() ) - deploy_status.append(operation_result) - - return CollectionResult(deploy_status) - - def _assert_object_definitions_are_correct( - object_type, object_definitions: List[FunctionSchema | ProcedureSchema] - ): - for definition in object_definitions: - database = definition.database - schema = definition.schema_name - name = definition.name - fqn_parts = len(name.split(".")) - if fqn_parts == 3 and database: - raise ClickException( - f"database of {object_type} {name} is redefined in its name" - ) - if fqn_parts >= 2 and schema: - raise ClickException( - f"schema of {object_type} {name} is redefined in its name" - ) - - def _find_existing_objects( - object_type: ObjectType, - objects: List[Dict], - om: ObjectManager, - ): - existing_objects = {} - for object_definition in objects: - identifier = build_udf_sproc_identifier( - object_definition, om, include_parameter_names=False + download_result = package_utils.download_unavailable_packages( + requirements=requirements, + target_dir=packages_dir, + anaconda_packages=anaconda_packages, + skip_version_check=skip_version_check, + pip_index_url=index_url, ) - try: - current_state = om.describe( - object_type=object_type.value.sf_name, - name=identifier, + if not download_result.succeeded: + raise ClickException(download_result.error_message) + + log.info("Checking to see if packages have shared (.so/.dll) libraries...") + if package_utils.detect_and_log_shared_libraries( + download_result.downloaded_packages_details + ): + # TODO: yes/no/ask logic should be removed in 3.0 + if not ( + allow_shared_libraries + or resolve_allow_shared_libraries_yes_no_ask( + deprecated_package_native_libraries + ) + ): + raise ClickException( + "Some packages contain shared (.so/.dll) libraries. " + "Try again with --allow-shared-libraries." + ) + if download_result.anaconda_packages: + anaconda_packages.write_requirements_file_in_snowflake_format( # type: ignore + file_path=snowpark_paths.snowflake_requirements_file, + requirements=download_result.anaconda_packages, ) - existing_objects[identifier] = current_state - except ProgrammingError: - pass - return existing_objects - - def get_app_stage_path(stage_name: Optional[str], project_name: str) -> str: - artifact_stage_directory = f"@{(stage_name or DEPLOYMENT_STAGE)}/{project_name}" - return artifact_stage_directory - - def _deploy_single_object( - manager: FunctionManager | ProcedureManager, - object_type: ObjectType, - object_definition: FunctionSchema | ProcedureSchema, - existing_objects: Dict[str, Dict], - snowflake_dependencies: List[str], - stage_artifact_path: str, - ): - identifier = build_udf_sproc_identifier( - object_definition, manager, include_parameter_names=False - ) - identifier_with_default_values = build_udf_sproc_identifier( - object_definition, - manager, - include_parameter_names=True, - include_default_values=True, + + zip_dir( + source=snowpark_paths.source.path, + dest_zip=snowpark_paths.artifact_file.path, ) - log.info("Deploying %s: %s", object_type, identifier_with_default_values) - - handler = object_definition.handler - returns = object_definition.returns - imports = object_definition.imports - external_access_integrations = object_definition.external_access_integrations - replace_object = False - - object_exists = identifier in existing_objects - if object_exists: - replace_object = check_if_replace_is_required( - object_type=object_type, - current_state=existing_objects[identifier], - handler=handler, - return_type=returns, - snowflake_dependencies=snowflake_dependencies, - external_access_integrations=external_access_integrations, - imports=imports, - stage_artifact_file=stage_artifact_path, + if any(packages_dir.iterdir()): + # if any packages were generated, append them to the .zip + zip_dir( + source=packages_dir.path, + dest_zip=snowpark_paths.artifact_file.path, + mode="a", ) - if object_exists and not replace_object: - return { - "object": identifier_with_default_values, - "type": str(object_type), - "status": "packages updated", - } - - create_or_replace_kwargs = { - "identifier": identifier_with_default_values, - "handler": handler, - "return_type": returns, - "artifact_file": stage_artifact_path, - "packages": snowflake_dependencies, - "runtime": object_definition.runtime, - "external_access_integrations": object_definition.external_access_integrations, - "secrets": object_definition.secrets, - "imports": imports, - } - if object_type == ObjectType.PROCEDURE: - create_or_replace_kwargs[ - "execute_as_caller" - ] = object_definition.execute_as_caller - manager.create_or_replace(**create_or_replace_kwargs) - - status = "created" if not object_exists else "definition updated" - return { - "object": identifier_with_default_values, - "type": str(object_type), - "status": status, - } + log.info("Package now ready: %s", snowpark_paths.artifact_file.path) - deprecated_pypi_download_option = typer.Option( - YesNoAsk.NO.value, - "--pypi-download", - help="Whether to download non-Anaconda packages from PyPi.", - hidden=True, - callback=deprecated_flag_callback_enum( - "--pypi-download flag is deprecated. Snowpark build command" - " always tries to download non-Anaconda packages from external index (PyPi by default)." - ), + return MessageResult( + f"Build done. Artifact path: {snowpark_paths.artifact_file.path}" ) - def _read_snowflake_requrements_file(file_path: SecurePath): - if not file_path.exists(): - return [] - return file_path.read_text( - file_size_limit_mb=DEFAULT_SIZE_LIMIT_MB - ).splitlines() - - @app.command("build", requires_connection=True) - @with_project_definition("snowpark") - def build( - ignore_anaconda: bool = IgnoreAnacondaOption, - allow_shared_libraries: bool = AllowSharedLibrariesOption, - index_url: Optional[str] = IndexUrlOption, - skip_version_check: bool = SkipVersionCheckOption, - deprecated_package_native_libraries: YesNoAsk = deprecated_allow_native_libraries_option( - "--package-native-libraries" - ), - deprecated_check_anaconda_for_pypi_deps: bool = DeprecatedCheckAnacondaForPyPiDependencies, - _deprecated_pypi_download: YesNoAsk = deprecated_pypi_download_option, - **options, - ) -> CommandResult: - """ - Builds the Snowpark project as a `.zip` archive that can be used by `deploy` command. - The archive is built using only the `src` directory specified in the project file. - """ - if not deprecated_check_anaconda_for_pypi_deps: - ignore_anaconda = True - snowpark_paths = SnowparkPackagePaths.for_snowpark_project( - project_root=SecurePath(cli_context.project_root), - snowpark_project_definition=cli_context.project_definition, - ) - log.info("Building package using sources from: %s", snowpark_paths.source.path) - anaconda_packages_manager = AnacondaPackagesManager() +class _SnowparkObject(Enum): + """This clas is used only for Snowpark execute where choice is limited.""" - with SecurePath.temporary_directory() as packages_dir: - if snowpark_paths.defined_requirements_file.exists(): - log.info("Resolving any requirements from requirements.txt...") - requirements = package_utils.parse_requirements( - requirements_file=snowpark_paths.defined_requirements_file, - ) - anaconda_packages = ( - AnacondaPackages.empty() - if ignore_anaconda - else anaconda_packages_manager.find_packages_available_in_snowflake_anaconda() - ) - download_result = package_utils.download_unavailable_packages( - requirements=requirements, - target_dir=packages_dir, - anaconda_packages=anaconda_packages, - skip_version_check=skip_version_check, - pip_index_url=index_url, - ) - if not download_result.succeeded: - raise ClickException(download_result.error_message) + PROCEDURE = str(ObjectType.PROCEDURE) + FUNCTION = str(ObjectType.FUNCTION) - log.info( - "Checking to see if packages have shared (.so/.dll) libraries..." - ) - if package_utils.detect_and_log_shared_libraries( - download_result.downloaded_packages_details - ): - # TODO: yes/no/ask logic should be removed in 3.0 - if not ( - allow_shared_libraries - or resolve_allow_shared_libraries_yes_no_ask( - deprecated_package_native_libraries - ) - ): - raise ClickException( - "Some packages contain shared (.so/.dll) libraries. " - "Try again with --allow-shared-libraries." - ) - if download_result.anaconda_packages: - anaconda_packages.write_requirements_file_in_snowflake_format( # type: ignore - file_path=snowpark_paths.snowflake_requirements_file, - requirements=download_result.anaconda_packages, - ) - zip_dir( - source=snowpark_paths.source.path, - dest_zip=snowpark_paths.artifact_file.path, - ) - if any(packages_dir.iterdir()): - # if any packages were generated, append them to the .zip - zip_dir( - source=packages_dir.path, - dest_zip=snowpark_paths.artifact_file.path, - mode="a", - ) +def _execute_object_method( + method_name: str, + object_type: _SnowparkObject, + **kwargs, +): + if object_type == _SnowparkObject.PROCEDURE: + manager = ProcedureManager() + elif object_type == _SnowparkObject.FUNCTION: + manager = FunctionManager() + else: + raise ClickException(f"Unknown object type: {object_type}") + + return getattr(manager, method_name)(**kwargs) + + +@app.command("execute", requires_connection=True) +def execute( + object_type: _SnowparkObject = ObjectTypeArgument, + execution_identifier: str = execution_identifier_argument( + "procedure/function", "hello(1, 'world')" + ), + **options, +) -> CommandResult: + """Executes a procedure or function in a specified environment.""" + cursor = _execute_object_method( + "execute", object_type=object_type, execution_identifier=execution_identifier + ) + return SingleQueryResult(cursor) - log.info("Package now ready: %s", snowpark_paths.artifact_file.path) - return MessageResult( - f"Build done. Artifact path: {snowpark_paths.artifact_file.path}" - ) +@app.command("list", requires_connection=True) +def list_( + object_type: _SnowparkObject = ObjectTypeArgument, + like: str = LikeOption, + scope: Tuple[str, str] = scope_option( + help_example="`list function --in database my_db`" + ), + **options, +): + """Lists all available procedures or functions.""" + object_list(object_type=object_type.value, like=like, scope=scope, **options) + + +@app.command("drop", requires_connection=True) +def drop( + object_type: _SnowparkObject = ObjectTypeArgument, + identifier: str = IdentifierArgument, + **options, +): + """Drop procedure or function.""" + object_drop(object_type=object_type.value, object_name=identifier, **options) - def _execute_object_method( - method_name: str, - object_type: _SnowparkObject, - **kwargs, - ): - if object_type == _SnowparkObject.PROCEDURE: - manager = ProcedureManager() - elif object_type == _SnowparkObject.FUNCTION: - manager = FunctionManager() - else: - raise ClickException(f"Unknown object type: {object_type}") - - return getattr(manager, method_name)(**kwargs) - - @app.command("execute", requires_connection=True) - def execute( - object_type: _SnowparkObject = ObjectTypeArgument, - execution_identifier: str = execution_identifier_argument( - "procedure/function", "hello(1, 'world')" - ), - **options, - ) -> CommandResult: - """Executes a procedure or function in a specified environment.""" - cursor = _execute_object_method( - "execute", - object_type=object_type, - execution_identifier=execution_identifier, - ) - return SingleQueryResult(cursor) - - @app.command("list", requires_connection=True) - def list_( - object_type: _SnowparkObject = ObjectTypeArgument, - like: str = LikeOption, - scope: Tuple[str, str] = scope_option( - help_example="`list function --in database my_db`" - ), - **options, - ): - """Lists all available procedures or functions.""" - object_list(object_type=object_type.value, like=like, scope=scope, **options) - - @app.command("drop", requires_connection=True) - def drop( - object_type: _SnowparkObject = ObjectTypeArgument, - identifier: str = IdentifierArgument, - **options, - ): - """Drop procedure or function.""" - object_drop(object_type=object_type.value, object_name=identifier, **options) - - @app.command("describe", requires_connection=True) - def describe( - object_type: _SnowparkObject = ObjectTypeArgument, - identifier: str = IdentifierArgument, - **options, - ): - """Provides description of a procedure or function.""" - object_describe( - object_type=object_type.value, object_name=identifier, **options - ) - return app +@app.command("describe", requires_connection=True) +def describe( + object_type: _SnowparkObject = ObjectTypeArgument, + identifier: str = IdentifierArgument, + **options, +): + """Provides description of a procedure or function.""" + object_describe(object_type=object_type.value, object_name=identifier, **options) diff --git a/src/snowflake/cli/plugins/snowpark/package/commands.py b/src/snowflake/cli/plugins/snowpark/package/commands.py index 78c72f731d..fb1cd30c30 100644 --- a/src/snowflake/cli/plugins/snowpark/package/commands.py +++ b/src/snowflake/cli/plugins/snowpark/package/commands.py @@ -37,210 +37,206 @@ ) from snowflake.cli.plugins.snowpark.zipper import zip_dir +app = SnowTyper( + name="package", + help="Manages custom Python packages for Snowpark", +) log = logging.getLogger(__name__) -def create_app(): - app = SnowTyper( - name="package", - help="Manages custom Python packages for Snowpark", - ) +lookup_install_option = typer.Option( + False, + "--pypi-download", + hidden=True, + callback=deprecated_flag_callback( + "Using --pypi-download is deprecated. Lookup command no longer checks for package in PyPi." + ), + help="Installs packages that are not available on the Snowflake Anaconda channel.", +) - lookup_install_option = typer.Option( - False, - "--pypi-download", - hidden=True, - callback=deprecated_flag_callback( - "Using --pypi-download is deprecated. Lookup command no longer checks for package in PyPi." - ), - help="Installs packages that are not available on the Snowflake Anaconda channel.", - ) +lookup_deprecated_install_option = typer.Option( + False, + "--yes", + "-y", + hidden=True, + callback=deprecated_flag_callback( + "Using --yes is deprecated. Lookup command no longer checks for package in PyPi." + ), + help="Installs packages that are not available on the Snowflake Anaconda channel.", +) - lookup_deprecated_install_option = typer.Option( - False, - "--yes", - "-y", - hidden=True, - callback=deprecated_flag_callback( - "Using --yes is deprecated. Lookup command no longer checks for package in PyPi." - ), - help="Installs packages that are not available on the Snowflake Anaconda channel.", + +@app.command("lookup", requires_connection=True) +def package_lookup( + package_name: str = typer.Argument( + ..., help="Name of the package.", show_default=False + ), + # todo: remove with 3.0 + _: bool = lookup_install_option, + __: bool = lookup_deprecated_install_option, + **options, +) -> CommandResult: + """ + Checks if a package is available on the Snowflake Anaconda channel. + """ + anaconda_packages_manager = AnacondaPackagesManager() + anaconda_packages = ( + anaconda_packages_manager.find_packages_available_in_snowflake_anaconda() ) - @app.command("lookup", requires_connection=True) - def package_lookup( - package_name: str = typer.Argument( - ..., help="Name of the package.", show_default=False - ), - # todo: remove with 3.0 - _: bool = lookup_install_option, - __: bool = lookup_deprecated_install_option, - **options, - ) -> CommandResult: + package = Requirement.parse(package_name) + if anaconda_packages.is_package_available(package=package): + msg = f"Package `{package_name}` is available in Anaconda" + if version := anaconda_packages.package_latest_version(package=package): + msg += f". Latest available version: {version}." + elif versions := anaconda_packages.package_versions(package=package): + msg += f" in versions: {', '.join(versions)}." + return MessageResult(msg) + + return MessageResult( + dedent( + f""" + Package `{package_name}` is not available in Anaconda. To prepare Snowpark compatible package run: + snow snowpark package create {package_name} """ - Checks if a package is available on the Snowflake Anaconda channel. - """ - anaconda_packages_manager = AnacondaPackagesManager() - anaconda_packages = ( - anaconda_packages_manager.find_packages_available_in_snowflake_anaconda() - ) - - package = Requirement.parse(package_name) - if anaconda_packages.is_package_available(package=package): - msg = f"Package `{package_name}` is available in Anaconda" - if version := anaconda_packages.package_latest_version(package=package): - msg += f". Latest available version: {version}." - elif versions := anaconda_packages.package_versions(package=package): - msg += f" in versions: {', '.join(versions)}." - return MessageResult(msg) - - return MessageResult( - dedent( - f""" - Package `{package_name}` is not available in Anaconda. To prepare Snowpark compatible package run: - snow snowpark package create {package_name} - """ - ) ) + ) - @app.command("upload", requires_connection=True) - def package_upload( - file: Path = typer.Option( - ..., - "--file", - "-f", - help="Path to the file to upload.", - exists=False, - ), - stage: str = typer.Option( - ..., - "--stage", - "-s", - help="Name of the stage in which to upload the file, not including the @ symbol.", - ), - overwrite: bool = typer.Option( - False, - "--overwrite", - "-o", - help="Overwrites the file if it already exists.", - ), - **options, - ) -> CommandResult: - """ - Uploads a Python package zip file to a Snowflake stage so it can be referenced in the imports of a procedure or function. - """ - return MessageResult(upload(file=file, stage=stage, overwrite=overwrite)) - deprecated_pypi_download_option = typer.Option( +@app.command("upload", requires_connection=True) +def package_upload( + file: Path = typer.Option( + ..., + "--file", + "-f", + help="Path to the file to upload.", + exists=False, + ), + stage: str = typer.Option( + ..., + "--stage", + "-s", + help="Name of the stage in which to upload the file, not including the @ symbol.", + ), + overwrite: bool = typer.Option( False, - "--pypi-download", - hidden=True, - callback=deprecated_flag_callback( - "Using --pypi-download is deprecated. Create command always checks for package in PyPi." - ), - help="Installs packages that are not available on the Snowflake Anaconda channel.", - ) + "--overwrite", + "-o", + help="Overwrites the file if it already exists.", + ), + **options, +) -> CommandResult: + """ + Uploads a Python package zip file to a Snowflake stage so it can be referenced in the imports of a procedure or function. + """ + return MessageResult(upload(file=file, stage=stage, overwrite=overwrite)) + + +deprecated_pypi_download_option = typer.Option( + False, + "--pypi-download", + hidden=True, + callback=deprecated_flag_callback( + "Using --pypi-download is deprecated. Create command always checks for package in PyPi." + ), + help="Installs packages that are not available on the Snowflake Anaconda channel.", +) - deprecated_install_option = typer.Option( - False, - "--yes", - "-y", - hidden=True, - help="Installs packages that are not available on the Snowflake Anaconda channel.", - callback=deprecated_flag_callback( - "Using --yes is deprecated. Create command always checks for package in PyPi." - ), - ) +deprecated_install_option = typer.Option( + False, + "--yes", + "-y", + hidden=True, + help="Installs packages that are not available on the Snowflake Anaconda channel.", + callback=deprecated_flag_callback( + "Using --yes is deprecated. Create command always checks for package in PyPi." + ), +) - @app.command("create", requires_connection=True) - def package_create( - name: str = typer.Argument( - ..., - help="Name of the package to create.", - ), - ignore_anaconda: bool = IgnoreAnacondaOption, - index_url: Optional[str] = IndexUrlOption, - skip_version_check: bool = SkipVersionCheckOption, - allow_shared_libraries: bool = AllowSharedLibrariesOption, - deprecated_allow_native_libraries: YesNoAsk = deprecated_allow_native_libraries_option( - "--allow-native-libraries" - ), - _deprecated_install_option: bool = deprecated_install_option, - _deprecated_install_packages: bool = deprecated_pypi_download_option, - **options, - ) -> CommandResult: - """ - Creates a Python package as a zip file that can be uploaded to a stage and imported for a Snowpark Python app. - """ - with SecurePath.temporary_directory() as packages_dir: - package = Requirement.parse(name) - anaconda_packages_manager = AnacondaPackagesManager() - download_result = download_unavailable_packages( - requirements=[package], - target_dir=packages_dir, - anaconda_packages=( - AnacondaPackages.empty() - if ignore_anaconda - else anaconda_packages_manager.find_packages_available_in_snowflake_anaconda() - ), - skip_version_check=skip_version_check, - pip_index_url=index_url, - ) - if not download_result.succeeded: - raise ClickException(download_result.error_message) - # check if package was detected as available - package_available_in_conda = any( - p.line == package.line for p in download_result.anaconda_packages - ) - if package_available_in_conda: - return MessageResult( - f"Package {name} is already available in Snowflake Anaconda Channel." - ) +@app.command("create", requires_connection=True) +def package_create( + name: str = typer.Argument( + ..., + help="Name of the package to create.", + ), + ignore_anaconda: bool = IgnoreAnacondaOption, + index_url: Optional[str] = IndexUrlOption, + skip_version_check: bool = SkipVersionCheckOption, + allow_shared_libraries: bool = AllowSharedLibrariesOption, + deprecated_allow_native_libraries: YesNoAsk = deprecated_allow_native_libraries_option( + "--allow-native-libraries" + ), + _deprecated_install_option: bool = deprecated_install_option, + _deprecated_install_packages: bool = deprecated_pypi_download_option, + **options, +) -> CommandResult: + """ + Creates a Python package as a zip file that can be uploaded to a stage and imported for a Snowpark Python app. + """ + with SecurePath.temporary_directory() as packages_dir: + package = Requirement.parse(name) + anaconda_packages_manager = AnacondaPackagesManager() + download_result = download_unavailable_packages( + requirements=[package], + target_dir=packages_dir, + anaconda_packages=( + AnacondaPackages.empty() + if ignore_anaconda + else anaconda_packages_manager.find_packages_available_in_snowflake_anaconda() + ), + skip_version_check=skip_version_check, + pip_index_url=index_url, + ) + if not download_result.succeeded: + raise ClickException(download_result.error_message) - # The package is not in anaconda, so we have to pack it - log.info("Checking to see if packages have shared (.so/.dll) libraries...") - if detect_and_log_shared_libraries( - download_result.downloaded_packages_details - ): - # TODO: yes/no/ask logic should be removed in 3.0 - if not ( - allow_shared_libraries - or resolve_allow_shared_libraries_yes_no_ask( - deprecated_allow_native_libraries - ) - ): - raise ClickException( - "Some packages contain shared (.so/.dll) libraries. " - "Try again with --allow-shared-libraries." - ) - - # The package is not in anaconda, so we have to pack it - # the package was downloaded once, pip wheel should use cache - zip_file = ( - f"{get_package_name_from_pip_wheel(name, index_url=index_url)}.zip" - ) - zip_dir(dest_zip=Path(zip_file), source=packages_dir.path) - message = dedent( - f""" - Package {zip_file} created. You can now upload it to a stage using - snow snowpark package upload -f {zip_file} -s ` - and reference it in your procedure or function. - Remember to add it to imports in the procedure or function definition. - """ + # check if package was detected as available + package_available_in_conda = any( + p.line == package.line for p in download_result.anaconda_packages + ) + if package_available_in_conda: + return MessageResult( + f"Package {name} is already available in Snowflake Anaconda Channel." ) - if download_result.anaconda_packages: - message += dedent( - f""" - The package {name} is successfully created, but depends on the following - Anaconda libraries. They need to be included in project requirements, - as their are not included in .zip. - """ + + # The package is not in anaconda, so we have to pack it + log.info("Checking to see if packages have shared (.so/.dll) libraries...") + if detect_and_log_shared_libraries(download_result.downloaded_packages_details): + # TODO: yes/no/ask logic should be removed in 3.0 + if not ( + allow_shared_libraries + or resolve_allow_shared_libraries_yes_no_ask( + deprecated_allow_native_libraries ) - message += "\n".join( - (req.line for req in download_result.anaconda_packages) + ): + raise ClickException( + "Some packages contain shared (.so/.dll) libraries. " + "Try again with --allow-shared-libraries." ) - return MessageResult(message) + # The package is not in anaconda, so we have to pack it + # the package was downloaded once, pip wheel should use cache + zip_file = f"{get_package_name_from_pip_wheel(name, index_url=index_url)}.zip" + zip_dir(dest_zip=Path(zip_file), source=packages_dir.path) + message = dedent( + f""" + Package {zip_file} created. You can now upload it to a stage using + snow snowpark package upload -f {zip_file} -s ` + and reference it in your procedure or function. + Remember to add it to imports in the procedure or function definition. + """ + ) + if download_result.anaconda_packages: + message += dedent( + f""" + The package {name} is successfully created, but depends on the following + Anaconda libraries. They need to be included in project requirements, + as their are not included in .zip. + """ + ) + message += "\n".join( + (req.line for req in download_result.anaconda_packages) + ) - return app + return MessageResult(message) diff --git a/src/snowflake/cli/plugins/snowpark/plugin_spec.py b/src/snowflake/cli/plugins/snowpark/plugin_spec.py index 016caec53c..c56142b97c 100644 --- a/src/snowflake/cli/plugins/snowpark/plugin_spec.py +++ b/src/snowflake/cli/plugins/snowpark/plugin_spec.py @@ -4,7 +4,7 @@ CommandType, plugin_hook_impl, ) -from snowflake.cli.plugins.snowpark import commands +from snowflake.cli.plugins.snowpark import app as snowpark_app @plugin_hook_impl @@ -12,5 +12,5 @@ def command_spec(): return CommandSpec( parent_command_path=SNOWCLI_ROOT_COMMAND_PATH, command_type=CommandType.COMMAND_GROUP, - typer_instance=commands.create_app(), + typer_instance=snowpark_app, ) diff --git a/src/snowflake/cli/plugins/sql/commands.py b/src/snowflake/cli/plugins/sql/commands.py index c4ce9c21c6..24f53ed3f9 100644 --- a/src/snowflake/cli/plugins/sql/commands.py +++ b/src/snowflake/cli/plugins/sql/commands.py @@ -12,65 +12,68 @@ from snowflake.cli.api.output.types import CommandResult, MultipleResults, QueryResult from snowflake.cli.plugins.sql.manager import SqlManager +# simple Typer with defaults because it won't become a command group as it contains only one command +app = SnowTyper() -def create_app(): - # simple Typer with defaults because it won't become a command group as it contains only one command - app = SnowTyper() - @app.command(name="sql", requires_connection=True, no_args_is_help=True) - def execute_sql( - query: Optional[str] = typer.Option( - None, - "--query", - "-q", - help="Query to execute.", - ), - files: Optional[List[Path]] = typer.Option( - None, - "--filename", - "-f", - exists=True, - file_okay=True, - dir_okay=False, - readable=True, - help="File to execute.", - ), - std_in: Optional[bool] = typer.Option( - False, - "--stdin", - "-i", - help="Read the query from standard input. Use it when piping input to this command.", - ), - data_override: List[str] = typer.Option( - None, - "--variable", - "-D", - help="String in format of key=value. If provided the SQL content will " - "be treated as template and rendered using provided data.", - ), - _: Optional[str] = project_definition_option(optional=True), - **options, - ) -> CommandResult: - """ - Executes Snowflake query. +def _parse_key_value(key_value_str: str): + parts = key_value_str.split("=") + if len(parts) < 2: + raise ValueError("Passed key-value pair does not comform with key=value format") - Use either query, filename or input option. + return parts[0], "=".join(parts[1:]) - Query to execute can be specified using query option, filename option (all queries from file will be executed) - or via stdin by piping output from other command. For example `cat my.sql | snow sql -i`. - The command supports variable substitution that happens on client-side. Both &VARIABLE or &{ VARIABLE } - syntax are supported. - """ - data = {} - if data_override: - data = {v.key: v.value for v in parse_key_value_variables(data_override)} +@app.command(name="sql", requires_connection=True, no_args_is_help=True) +def execute_sql( + query: Optional[str] = typer.Option( + None, + "--query", + "-q", + help="Query to execute.", + ), + files: Optional[List[Path]] = typer.Option( + None, + "--filename", + "-f", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + help="File to execute.", + ), + std_in: Optional[bool] = typer.Option( + False, + "--stdin", + "-i", + help="Read the query from standard input. Use it when piping input to this command.", + ), + data_override: List[str] = typer.Option( + None, + "--variable", + "-D", + help="String in format of key=value. If provided the SQL content will " + "be treated as template and rendered using provided data.", + ), + _: Optional[str] = project_definition_option(optional=True), + **options, +) -> CommandResult: + """ + Executes Snowflake query. - single_statement, cursors = SqlManager().execute( - query, files, std_in, data=data - ) - if single_statement: - return QueryResult(next(cursors)) - return MultipleResults((QueryResult(c) for c in cursors)) + Use either query, filename or input option. - return app + Query to execute can be specified using query option, filename option (all queries from file will be executed) + or via stdin by piping output from other command. For example `cat my.sql | snow sql -i`. + + The command supports variable substitution that happens on client-side. Both &VARIABLE or &{ VARIABLE } + syntax are supported. + """ + data = {} + if data_override: + data = {v.key: v.value for v in parse_key_value_variables(data_override)} + + single_statement, cursors = SqlManager().execute(query, files, std_in, data=data) + if single_statement: + return QueryResult(next(cursors)) + return MultipleResults((QueryResult(c) for c in cursors)) diff --git a/src/snowflake/cli/plugins/sql/plugin_spec.py b/src/snowflake/cli/plugins/sql/plugin_spec.py index bc8a5c30b9..94c549a210 100644 --- a/src/snowflake/cli/plugins/sql/plugin_spec.py +++ b/src/snowflake/cli/plugins/sql/plugin_spec.py @@ -12,5 +12,5 @@ def command_spec(): return CommandSpec( parent_command_path=SNOWCLI_ROOT_COMMAND_PATH, command_type=CommandType.SINGLE_COMMAND, - typer_instance=commands.create_app(), + typer_instance=commands.app, ) diff --git a/src/snowflake/cli/plugins/stage/commands.py b/src/snowflake/cli/plugins/stage/commands.py index 4055202b88..1c22a9c20a 100644 --- a/src/snowflake/cli/plugins/stage/commands.py +++ b/src/snowflake/cli/plugins/stage/commands.py @@ -31,141 +31,36 @@ from snowflake.cli.plugins.stage.diff import DiffResult, compute_stage_diff from snowflake.cli.plugins.stage.manager import OnErrorType, StageManager -StageNameArgument = typer.Argument(..., help="Name of the stage.", show_default=False) - - -def create_app(): - app = SnowTyper( - name="stage", - help="Manages stages.", - ) - - add_object_command_aliases( - app=app, - object_type=ObjectType.STAGE, - name_argument=StageNameArgument, - like_option=like_option( - help_example='`list --like "my%"` lists all stages that begin with “my”', - ), - scope_option=scope_option(help_example="`list --in database my_db`"), - ) - - @app.command("list-files", requires_connection=True) - def stage_list_files_cmd( - stage_name: str = StageNameArgument, pattern=PatternOption, **options - ) -> CommandResult: - """ - Lists the stage contents. - """ - return stage_list_files(stage_name, pattern, **options) - - @app.command("copy", requires_connection=True) - def copy_cmd( - source_path: str = typer.Argument( - help="Source path for copy operation. Can be either stage path or local.", - show_default=False, - ), - destination_path: str = typer.Argument( - help="Target directory path for copy operation. Should be stage if source is local or local if source is stage.", - show_default=False, - ), - overwrite: bool = typer.Option( - False, - help="Overwrites existing files in the target path.", - ), - parallel: int = typer.Option( - 4, - help="Number of parallel threads to use when uploading files.", - ), - recursive: bool = typer.Option( - False, - help="Copy files recursively with directory structure.", - ), - **options, - ) -> CommandResult: - """ - Copies all files from target path to target directory. This works for both uploading - to and downloading files from the stage. - """ - return copy( - source_path=source_path, - destination_path=destination_path, - overwrite=overwrite, - parallel=parallel, - recursive=recursive, - **options, - ) - - @app.command("create", requires_connection=True) - def stage_create_cmd( - stage_name: str = StageNameArgument, **options - ) -> CommandResult: - """ - Creates a named stage if it does not already exist. - """ - return stage_create(stage_name=stage_name, **options) - - @app.command("remove", requires_connection=True) - def stage_remove_cmd( - stage_name: str = StageNameArgument, - file_name: str = typer.Argument( - ..., - help="Name of the file to remove.", - show_default=False, - ), - **options, - ) -> CommandResult: - """ - Removes a file from a stage. - """ - return stage_remove(stage_name=stage_name, file_name=file_name, **options) - - @app.command("diff", hidden=True, requires_connection=True) - def stage_diff_cmd( - stage_name: str = typer.Argument( - help="Fully qualified name of a stage", - show_default=False, - ), - folder_name: str = typer.Argument( - help="Path to local folder", - show_default=False, - ), - **options, - ) -> ObjectResult: - """ - Diffs a stage with a local folder. - """ - return stage_diff(stage_name=stage_name, folder_name=folder_name, **options) +app = SnowTyper( + name="stage", + help="Manages stages.", +) - @app.command("execute", requires_connection=True) - def execute_cmd( - stage_path: str = typer.Argument( - ..., - help="Stage path with files to be execute. For example `@stage/dev/*`.", - show_default=False, - ), - on_error: OnErrorType = OnErrorOption, - variables: Optional[List[str]] = VariablesOption, - **options, - ) -> CollectionResult: - """ - Execute immediate all files from the stage path. Files can be filtered with glob like pattern, - e.g. `@stage/*.sql`, `@stage/dev/*`. Only files with `.sql` extension will be executed. - """ - return execute( - stage_path=stage_path, on_error=on_error, variables=variables, **options - ) +StageNameArgument = typer.Argument(..., help="Name of the stage.", show_default=False) - return app +add_object_command_aliases( + app=app, + object_type=ObjectType.STAGE, + name_argument=StageNameArgument, + like_option=like_option( + help_example='`list --like "my%"` lists all stages that begin with “my”', + ), + scope_option=scope_option(help_example="`list --in database my_db`"), +) +@app.command("list-files", requires_connection=True) def stage_list_files( - stage_name: str, pattern: Optional[str], **options + stage_name: str = StageNameArgument, pattern=PatternOption, **options ) -> CommandResult: + """ + Lists the stage contents. + """ cursor = StageManager().list_files(stage_name=stage_name, pattern=pattern) return QueryResult(cursor) +@app.command("copy", requires_connection=True) def copy( source_path: str = typer.Argument( help="Source path for copy operation. Can be either stage path or local.", @@ -189,6 +84,10 @@ def copy( ), **options, ) -> CommandResult: + """ + Copies all files from target path to target directory. This works for both uploading + to and downloading files from the stage. + """ is_get = is_stage_path(source_path) is_put = is_stage_path(destination_path) @@ -217,37 +116,67 @@ def copy( ) -def stage_create(stage_name: str, **options) -> CommandResult: +@app.command("create", requires_connection=True) +def stage_create(stage_name: str = StageNameArgument, **options) -> CommandResult: + """ + Creates a named stage if it does not already exist. + """ cursor = StageManager().create(stage_name=stage_name) return SingleQueryResult(cursor) -def stage_remove(stage_name: str, file_name: str, **options) -> CommandResult: +@app.command("remove", requires_connection=True) +def stage_remove( + stage_name: str = StageNameArgument, + file_name: str = typer.Argument( + ..., + help="Name of the file to remove.", + show_default=False, + ), + **options, +) -> CommandResult: + """ + Removes a file from a stage. + """ + cursor = StageManager().remove(stage_name=stage_name, path=file_name) return SingleQueryResult(cursor) +@app.command("diff", hidden=True, requires_connection=True) def stage_diff( - stage_name: str, - folder_name: str, + stage_name: str = typer.Argument( + help="Fully qualified name of a stage", + show_default=False, + ), + folder_name: str = typer.Argument( + help="Path to local folder", + show_default=False, + ), **options, ) -> ObjectResult: """ Diffs a stage with a local folder. """ - diff: DiffResult = compute_stage_diff(Path(folder_name), stage_name) return ObjectResult(str(diff)) +@app.command("execute", requires_connection=True) def execute( - stage_path: str, on_error: OnErrorType, variables: Optional[List[str]], **options -) -> CollectionResult: + stage_path: str = typer.Argument( + ..., + help="Stage path with files to be execute. For example `@stage/dev/*`.", + show_default=False, + ), + on_error: OnErrorType = OnErrorOption, + variables: Optional[List[str]] = VariablesOption, + **options, +): """ Execute immediate all files from the stage path. Files can be filtered with glob like pattern, e.g. `@stage/*.sql`, `@stage/dev/*`. Only files with `.sql` extension will be executed. """ - results = StageManager().execute( stage_path=stage_path, on_error=on_error, variables=variables ) diff --git a/src/snowflake/cli/plugins/stage/plugin_spec.py b/src/snowflake/cli/plugins/stage/plugin_spec.py index a6871d4731..319a1c2556 100644 --- a/src/snowflake/cli/plugins/stage/plugin_spec.py +++ b/src/snowflake/cli/plugins/stage/plugin_spec.py @@ -4,7 +4,7 @@ CommandType, plugin_hook_impl, ) -from snowflake.cli.plugins.stage import commands +from snowflake.cli.plugins.stage.commands import app as stage_app @plugin_hook_impl @@ -12,5 +12,5 @@ def command_spec(): return CommandSpec( parent_command_path=SNOWCLI_ROOT_COMMAND_PATH, command_type=CommandType.COMMAND_GROUP, - typer_instance=commands.create_app(), + typer_instance=stage_app, ) diff --git a/src/snowflake/cli/plugins/streamlit/commands.py b/src/snowflake/cli/plugins/streamlit/commands.py index e8545a9dfa..6bebd4edb0 100644 --- a/src/snowflake/cli/plugins/streamlit/commands.py +++ b/src/snowflake/cli/plugins/streamlit/commands.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from pathlib import Path import click @@ -27,6 +28,13 @@ ) from snowflake.cli.plugins.streamlit.manager import StreamlitManager +app = SnowTyper( + name="streamlit", + help="Manages a Streamlit app in Snowflake.", +) +log = logging.getLogger(__name__) + + StreamlitNameArgument = typer.Argument( ..., help="Name of the Streamlit app.", show_default=False ) @@ -37,120 +45,113 @@ is_flag=True, ) +add_init_command( + app, + project_type="Streamlit", + template="default_streamlit", + help_message="Name of the Streamlit app project directory you want to create. Defaults to `example_streamlit`.", +) -def create_app(): - app = SnowTyper( - name="streamlit", - help="Manages a Streamlit app in Snowflake.", - ) +add_object_command_aliases( + app=app, + object_type=ObjectType.STREAMLIT, + name_argument=StreamlitNameArgument, + like_option=like_option( + help_example='`list --like "my%"` lists all streamlit apps that begin with “my”' + ), + scope_option=scope_option(help_example="`list --in database my_db`"), +) - add_init_command( - app, - project_type="Streamlit", - template="default_streamlit", - help_message="Name of the Streamlit app project directory you want to create. Defaults to `example_streamlit`.", - ) - add_object_command_aliases( - app=app, - object_type=ObjectType.STREAMLIT, - name_argument=StreamlitNameArgument, - like_option=like_option( - help_example='`list --like "my%"` lists all streamlit apps that begin with “my”' - ), - scope_option=scope_option(help_example="`list --in database my_db`"), +@app.command("share", requires_connection=True) +def streamlit_share( + name: str = StreamlitNameArgument, + to_role: str = typer.Argument( + ..., help="Role with which to share the Streamlit app." + ), + **options, +) -> CommandResult: + """ + Shares a Streamlit app with another role. + """ + cursor = StreamlitManager().share(streamlit_name=name, to_role=to_role) + return SingleQueryResult(cursor) + + +def _default_file_callback(param_name: str): + from click.core import ParameterSource # type: ignore + + def _check_file_exists_if_not_default(ctx: click.Context, value): + if ( + ctx.get_parameter_source(param_name) != ParameterSource.DEFAULT # type: ignore + and value + and not Path(value).exists() + ): + raise ClickException(f"Provided file {value} does not exist") + return Path(value) + + return _check_file_exists_if_not_default + + +@app.command("deploy", requires_connection=True) +@with_project_definition("streamlit") +@with_experimental_behaviour() +def streamlit_deploy( + replace: bool = ReplaceOption( + help="Replace the Streamlit app if it already exists." + ), + open_: bool = OpenOption, + **options, +) -> CommandResult: + """ + Deploys a Streamlit app defined in the project definition file (snowflake.yml). By default, the command uploads + environment.yml and any other pages or folders, if present. If you don’t specify a stage name, the `streamlit` + stage is used. If the specified stage does not exist, the command creates it. + """ + streamlit: Streamlit = cli_context.project_definition + if not streamlit: + return MessageResult("No streamlit were specified in project definition.") + + environment_file = streamlit.env_file + if environment_file and not Path(environment_file).exists(): + raise ClickException(f"Provided file {environment_file} does not exist") + elif environment_file is None: + environment_file = "environment.yml" + + pages_dir = streamlit.pages_dir + if pages_dir and not Path(pages_dir).exists(): + raise ClickException(f"Provided file {pages_dir} does not exist") + elif pages_dir is None: + pages_dir = "pages" + + streamlit_name = FQN.from_identifier_model(streamlit).using_context() + + url = StreamlitManager().deploy( + streamlit=streamlit_name, + environment_file=Path(environment_file), + pages_dir=Path(pages_dir), + stage_name=streamlit.stage, + main_file=Path(streamlit.main_file), + replace=replace, + query_warehouse=streamlit.query_warehouse, + additional_source_files=streamlit.additional_source_files, + **options, ) - @app.command("share", requires_connection=True) - def streamlit_share( - name: str = StreamlitNameArgument, - to_role: str = typer.Argument( - ..., help="Role with which to share the Streamlit app." - ), - **options, - ) -> CommandResult: - """ - Shares a Streamlit app with another role. - """ - cursor = StreamlitManager().share(streamlit_name=name, to_role=to_role) - return SingleQueryResult(cursor) - - def _default_file_callback(param_name: str): - from click.core import ParameterSource # type: ignore - - def _check_file_exists_if_not_default(ctx: click.Context, value): - if ( - ctx.get_parameter_source(param_name) != ParameterSource.DEFAULT # type: ignore - and value - and not Path(value).exists() - ): - raise ClickException(f"Provided file {value} does not exist") - return Path(value) - - return _check_file_exists_if_not_default - - @app.command("deploy", requires_connection=True) - @with_project_definition("streamlit") - @with_experimental_behaviour() - def streamlit_deploy( - replace: bool = ReplaceOption( - help="Replace the Streamlit app if it already exists." - ), - open_: bool = OpenOption, - **options, - ) -> CommandResult: - """ - Deploys a Streamlit app defined in the project definition file (snowflake.yml). By default, the command uploads - environment.yml and any other pages or folders, if present. If you don’t specify a stage name, the `streamlit` - stage is used. If the specified stage does not exist, the command creates it. - """ - streamlit: Streamlit = cli_context.project_definition - if not streamlit: - return MessageResult("No streamlit were specified in project definition.") - - environment_file = streamlit.env_file - if environment_file and not Path(environment_file).exists(): - raise ClickException(f"Provided file {environment_file} does not exist") - elif environment_file is None: - environment_file = "environment.yml" - - pages_dir = streamlit.pages_dir - if pages_dir and not Path(pages_dir).exists(): - raise ClickException(f"Provided file {pages_dir} does not exist") - elif pages_dir is None: - pages_dir = "pages" - - streamlit_name = FQN.from_identifier_model(streamlit).using_context() - - url = StreamlitManager().deploy( - streamlit=streamlit_name, - environment_file=Path(environment_file), - pages_dir=Path(pages_dir), - stage_name=streamlit.stage, - main_file=Path(streamlit.main_file), - replace=replace, - query_warehouse=streamlit.query_warehouse, - additional_source_files=streamlit.additional_source_files, - **options, - ) - - if open_: - typer.launch(url) - - return MessageResult( - f"Streamlit successfully deployed and available under {url}" - ) - - @app.command("get-url", requires_connection=True) - def get_url( - name: str = StreamlitNameArgument, - open_: bool = OpenOption, - **options, - ): - """Returns a URL to the specified Streamlit app""" - url = StreamlitManager().get_url(streamlit_name=name) - if open_: - typer.launch(url) - return MessageResult(url) - - return app + if open_: + typer.launch(url) + + return MessageResult(f"Streamlit successfully deployed and available under {url}") + + +@app.command("get-url", requires_connection=True) +def get_url( + name: str = StreamlitNameArgument, + open_: bool = OpenOption, + **options, +): + """Returns a URL to the specified Streamlit app""" + url = StreamlitManager().get_url(streamlit_name=name) + if open_: + typer.launch(url) + return MessageResult(url) diff --git a/src/snowflake/cli/plugins/streamlit/plugin_spec.py b/src/snowflake/cli/plugins/streamlit/plugin_spec.py index 07d0ab08d3..e31aec26e9 100644 --- a/src/snowflake/cli/plugins/streamlit/plugin_spec.py +++ b/src/snowflake/cli/plugins/streamlit/plugin_spec.py @@ -12,5 +12,5 @@ def command_spec(): return CommandSpec( parent_command_path=SNOWCLI_ROOT_COMMAND_PATH, command_type=CommandType.COMMAND_GROUP, - typer_instance=commands.create_app(), + typer_instance=commands.app, ) diff --git a/tests_e2e/__snapshots__/test_error_handling.ambr b/tests_e2e/__snapshots__/test_error_handling.ambr deleted file mode 100644 index 0ed2d80460..0000000000 --- a/tests_e2e/__snapshots__/test_error_handling.ambr +++ /dev/null @@ -1,21 +0,0 @@ -# serializer version: 1 -# name: test_corrupted_config_in_default_location - ''' - ╭─ Error ──────────────────────────────────────────────────────────────────────╮ - │ Configuration file seems to be corrupted. Key "demo" already exists. at line │ - │ 2 col 18 │ - ╰──────────────────────────────────────────────────────────────────────────────╯ - - ''' -# --- -# name: test_corrupted_config_in_default_location.1 - ''' - +-----------------------------------------------------+ - | connection_name | parameters | is_default | - |-----------------+----------------------+------------| - | dev | {} | False | - | integration | {'schema': 'public'} | False | - +-----------------------------------------------------+ - - ''' -# --- diff --git a/tests_e2e/config/malformatted_config.toml b/tests_e2e/config/malformatted_config.toml new file mode 100755 index 0000000000..606ed17ec1 --- /dev/null +++ b/tests_e2e/config/malformatted_config.toml @@ -0,0 +1,5 @@ +[connections.dev] +[connections.spcs] +[connections.integration] +schema = "public" +schema = "public" diff --git a/tests_e2e/test_error_handling.py b/tests_e2e/test_error_handling.py index 777048b69a..93950f8872 100644 --- a/tests_e2e/test_error_handling.py +++ b/tests_e2e/test_error_handling.py @@ -1,6 +1,5 @@ import os import subprocess -from pathlib import Path import pytest @@ -50,30 +49,3 @@ def test_error_traceback_disabled_without_debug(snowcli, test_root_path): assert result_debug.returncode == 1 assert not result_debug.stdout assert traceback_msg in result_debug.stderr - - -@pytest.mark.e2e -def test_corrupted_config_in_default_location( - snowcli, temp_dir, isolate_default_config_location, test_root_path, snapshot -): - default_config = Path(temp_dir) / "config.toml" - default_config.write_text("[connections.demo]\n[connections.demo]") - default_config.chmod(0o600) - # corrupted config should produce human-friendly error - result_err = subprocess.run( - [snowcli, "connection", "list"], - capture_output=True, - text=True, - ) - assert result_err.returncode == 1 - assert result_err.stderr == snapshot - - # corrupted config in default location should not influence one passed with --config-file flag - healthy_config = test_root_path / "config" / "config.toml" - result_healthy = subprocess.run( - [snowcli, "--config-file", healthy_config, "connection", "list"], - capture_output=True, - text=True, - ) - assert result_healthy.returncode == 0, result_healthy.stderr - assert "dev" in result_healthy.stdout and "integration" in result_healthy.stdout