From ce90044d1bbde44405366b7d078d028a069e4505 Mon Sep 17 00:00:00 2001 From: Michel El Nacouzi Date: Wed, 11 Dec 2024 07:54:23 -0500 Subject: [PATCH 1/5] Fix not able to add patch to quoted versions (#1943) --- RELEASE-NOTES.md | 1 + src/snowflake/cli/api/project/util.py | 4 ++ tests/project/test_util.py | 22 +++++++++ tests/test_utils.py | 2 +- tests_integration/nativeapp/test_version.py | 50 +++++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index b5f3ccff40..df330f5bd1 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -22,6 +22,7 @@ ## Fixes and improvements * Fixed crashes with older x86_64 Intel CPUs. +* Fixed inability to add patches to lowercase quoted versions # v3.2.0 diff --git a/src/snowflake/cli/api/project/util.py b/src/snowflake/cli/api/project/util.py index 564ebbd867..9564e2bae2 100644 --- a/src/snowflake/cli/api/project/util.py +++ b/src/snowflake/cli/api/project/util.py @@ -148,6 +148,10 @@ def unquote_identifier(identifier: str) -> str: string for a LIKE clause, or to match an identifier passed back as a value from a SQL statement. """ + # ensure input is a valid identifier - otherwise, it could accidentally uppercase + # a quoted identifier + identifier = to_identifier(identifier) + if match := re.fullmatch(QUOTED_IDENTIFIER_REGEX, identifier): return match.group(1).replace('""', '"') # unquoted identifiers are internally represented as uppercase diff --git a/tests/project/test_util.py b/tests/project/test_util.py index 02a1ac272c..dc33902968 100644 --- a/tests/project/test_util.py +++ b/tests/project/test_util.py @@ -29,6 +29,7 @@ to_identifier, to_quoted_identifier, to_string_literal, + unquote_identifier, ) VALID_UNQUOTED_IDENTIFIERS = ( @@ -335,3 +336,24 @@ def test_identifier_to_str(identifier, expected_value): ) def test_sanitize_identifier(identifier, expected_value): assert sanitize_identifier(identifier) == expected_value + + +@pytest.mark.parametrize( + "identifier, expected", + [ + # valid unquoted id -> return upper case version + ("Id_1", "ID_1"), + # valid quoted id -> remove quotes and keep case + ('"Id""1"', 'Id"1'), + # unquoted id with special characters -> treat it as quoted ID and reserve case + ("Id.aBc", "Id.aBc"), + # unquoted id with double quotes inside -> treat is quoted ID + ('Id"1', 'Id"1'), + # quoted id with escaped double quotes -> unescape and keep case + ('"Id""1"', 'Id"1'), + # empty string -> return the same + ("", ""), + ], +) +def test_unquote_identifier(identifier, expected): + assert unquote_identifier(identifier) == expected diff --git a/tests/test_utils.py b/tests/test_utils.py index 10eadc479a..7e24e002e6 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -165,7 +165,7 @@ def test_path_resolver(mock_system, argument, expected): ("my_app", "MY_APP"), ('"My App"', "My%20App"), ("SYSTEM$GET", "SYSTEM%24GET"), - ("mailorder_!@#$%^&*()/_app", "MAILORDER_!%40%23%24%25%5E%26*()%2F_APP"), + ("mailorder_!@#$%^&*()/_app", "mailorder_!%40%23%24%25%5E%26*()%2F_app"), ('"Mailorder *App* is /cool/"', "Mailorder%20*App*%20is%20%2Fcool%2F"), ], ) diff --git a/tests_integration/nativeapp/test_version.py b/tests_integration/nativeapp/test_version.py index 701f9e9fc0..61d8fb5fe9 100644 --- a/tests_integration/nativeapp/test_version.py +++ b/tests_integration/nativeapp/test_version.py @@ -517,3 +517,53 @@ def test_version_create_with_manage_versions_only( ] ) assert result.exit_code == 0, result.output + + +@pytest.mark.integration +def test_nativeapp_version_create_quoted_identifiers( + runner, + snowflake_session, + default_username, + resource_suffix, + nativeapp_project_directory, +): + project_name = "myapp" + with nativeapp_project_directory("napp_init_v2"): + package_name = f"{project_name}_pkg_{default_username}{resource_suffix}".upper() + + # create version + result = runner.invoke_with_connection_json( + ["app", "version", "create", "v1.0"] + ) + assert result.exit_code == 0 + + # create another patch + result = runner.invoke_with_connection_json( + ["app", "version", "create", "v1.0"] + ) + assert result.exit_code == 0 + + # create custom patch + result = runner.invoke_with_connection_json( + ["app", "version", "create", "v1.0", "--patch", "4"] + ) + assert result.exit_code == 0 + + # app package contains 3 patches for version v1.0 + expect = row_from_snowflake_session( + snowflake_session.execute_string( + f"show versions in application package {package_name}" + ) + ) + assert contains_row_with(expect, {"version": "v1.0", "patch": 0}) + assert contains_row_with(expect, {"version": "v1.0", "patch": 1}) + assert contains_row_with(expect, {"version": "v1.0", "patch": 4}) + + # drop the version + result_drop = runner.invoke_with_connection_json( + ["app", "version", "drop", "v1.0", "--force"] + ) + assert result_drop.exit_code == 0 + + actual = runner.invoke_with_connection_json(["app", "version", "list"]) + assert len(actual.json) == 0 From 8f559e356e515759c12c1eda4ebcd099e96f658c Mon Sep 17 00:00:00 2001 From: Michel El Nacouzi Date: Wed, 11 Dec 2024 10:07:31 -0500 Subject: [PATCH 2/5] Add release directives support to SnowCLI (#1938) --- RELEASE-NOTES.md | 4 + .../cli/_plugins/nativeapp/commands.py | 4 + .../cli/_plugins/nativeapp/constants.py | 4 + .../nativeapp/entities/application_package.py | 157 ++++- .../nativeapp/release_directive/__init__.py | 13 + .../nativeapp/release_directive/commands.py | 165 +++++ .../cli/_plugins/nativeapp/sf_sql_facade.py | 300 +++++++- src/snowflake/cli/api/entities/common.py | 10 +- src/snowflake/cli/api/errno.py | 7 + src/snowflake/cli/api/project/util.py | 41 ++ tests/__snapshots__/test_help_messages.ambr | 447 +++++++++++- tests/nativeapp/factories.py | 1 - tests/nativeapp/fixtures.py | 2 +- .../test_application_package_entity.py | 651 ++++++++++++++++++ tests/nativeapp/test_sf_sql_facade.py | 624 +++++++++++++++++ tests/nativeapp/utils.py | 5 + tests/project/test_util.py | 79 +++ 17 files changed, 2451 insertions(+), 63 deletions(-) create mode 100644 src/snowflake/cli/_plugins/nativeapp/release_directive/__init__.py create mode 100644 src/snowflake/cli/_plugins/nativeapp/release_directive/commands.py diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index df330f5bd1..739758cab1 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -19,6 +19,10 @@ ## Deprecations ## New additions +* Add Release Directives support by introducing the following commands: + * `snow app release-directive list` + * `snow app release-directive set` + * `snow app release-directive unset` ## Fixes and improvements * Fixed crashes with older x86_64 Intel CPUs. diff --git a/src/snowflake/cli/_plugins/nativeapp/commands.py b/src/snowflake/cli/_plugins/nativeapp/commands.py index 55ca0019a0..2d5bcbf901 100644 --- a/src/snowflake/cli/_plugins/nativeapp/commands.py +++ b/src/snowflake/cli/_plugins/nativeapp/commands.py @@ -31,6 +31,9 @@ from snowflake.cli._plugins.nativeapp.entities.application_package import ( ApplicationPackageEntityModel, ) +from snowflake.cli._plugins.nativeapp.release_directive.commands import ( + app as release_directives_app, +) from snowflake.cli._plugins.nativeapp.sf_facade import get_snowflake_facade from snowflake.cli._plugins.nativeapp.v2_conversions.compat import ( find_entity, @@ -67,6 +70,7 @@ help="Manages a Snowflake Native App", ) app.add_typer(versions_app) +app.add_typer(release_directives_app) log = logging.getLogger(__name__) diff --git a/src/snowflake/cli/_plugins/nativeapp/constants.py b/src/snowflake/cli/_plugins/nativeapp/constants.py index 11d439b37f..829e2751f9 100644 --- a/src/snowflake/cli/_plugins/nativeapp/constants.py +++ b/src/snowflake/cli/_plugins/nativeapp/constants.py @@ -22,7 +22,11 @@ OWNER_COL = "owner" VERSION_COL = "version" PATCH_COL = "patch" +CHANNEL_COL = "release_channel_name" AUTHORIZE_TELEMETRY_COL = "authorize_telemetry_event_sharing" INTERNAL_DISTRIBUTION = "internal" EXTERNAL_DISTRIBUTION = "external" + +DEFAULT_CHANNEL = "DEFAULT" +DEFAULT_DIRECTIVE = "DEFAULT" diff --git a/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py b/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py index c61b7939dc..35d5963f73 100644 --- a/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py +++ b/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py @@ -4,7 +4,7 @@ import re from pathlib import Path from textwrap import dedent -from typing import List, Literal, Optional, Union +from typing import Any, List, Literal, Optional, Union import typer from click import BadOptionUsage, ClickException @@ -21,6 +21,8 @@ from snowflake.cli._plugins.nativeapp.constants import ( ALLOWED_SPECIAL_COMMENTS, COMMENT_COL, + DEFAULT_CHANNEL, + DEFAULT_DIRECTIVE, EXTERNAL_DISTRIBUTION, INTERNAL_DISTRIBUTION, NAME_COL, @@ -78,9 +80,13 @@ from snowflake.cli.api.project.schemas.v1.native_app.path_mapping import PathMapping from snowflake.cli.api.project.util import ( SCHEMA_AND_NAME, + VALID_IDENTIFIER_REGEX, append_test_resource_suffix, extract_schema, + identifier_in_list, identifier_to_show_like_pattern, + same_identifiers, + sql_match, to_identifier, unquote_identifier, ) @@ -544,6 +550,151 @@ def action_version_drop( f"Version {version} in application package {self.name} dropped successfully." ) + def action_release_directive_list( + self, + action_ctx: ActionContext, + release_channel: Optional[str], + like: str, + *args, + **kwargs, + ) -> list[dict[str, Any]]: + """ + Get all existing release directives for an application package. + Limit the results to a specific release channel, if provided. + + If `like` is provided, only release directives matching the SQL LIKE pattern are listed. + """ + available_release_channels = get_snowflake_facade().show_release_channels( + self.name, self.role + ) + + # assume no release channel used if user selects default channel and release channels are not enabled + if ( + release_channel + and same_identifiers(release_channel, DEFAULT_CHANNEL) + and not available_release_channels + ): + release_channel = None + + release_channel_names = [c.get("name") for c in available_release_channels] + if release_channel and not identifier_in_list( + release_channel, release_channel_names + ): + raise ClickException( + f"Release channel {release_channel} does not exist in application package {self.name}." + ) + + release_directives = get_snowflake_facade().show_release_directives( + package_name=self.name, + role=self.role, + release_channel=release_channel, + ) + + return [ + directive + for directive in release_directives + if sql_match(pattern=like, value=directive.get("name", "")) + ] + + def action_release_directive_set( + self, + action_ctx: ActionContext, + version: str, + patch: int, + release_directive: str, + release_channel: str, + target_accounts: Optional[list[str]], + *args, + **kwargs, + ): + """ + Sets a release directive to the specified version and patch using the specified release channel. + Target accounts can only be specified for non-default release directives. + + For non-default release directives, update the existing release directive if target accounts are not provided. + """ + if target_accounts: + for account in target_accounts: + if not re.fullmatch( + f"{VALID_IDENTIFIER_REGEX}\\.{VALID_IDENTIFIER_REGEX}", account + ): + raise ClickException( + f"Target account {account} is not in a valid format. Make sure you provide the target account in the format 'org.account'." + ) + + if target_accounts and same_identifiers(release_directive, DEFAULT_DIRECTIVE): + raise BadOptionUsage( + "target_accounts", + "Target accounts can only be specified for non-default named release directives.", + ) + + available_release_channels = get_snowflake_facade().show_release_channels( + self.name, self.role + ) + + release_channel_names = [c.get("name") for c in available_release_channels] + + if not same_identifiers( + release_channel, DEFAULT_CHANNEL + ) and not identifier_in_list(release_channel, release_channel_names): + raise ClickException( + f"Release channel {release_channel} does not exist in application package {self.name}." + ) + + if ( + not same_identifiers(release_directive, DEFAULT_DIRECTIVE) + and not target_accounts + ): + # if it is a non-default release directive with no target accounts specified, + # it means that the user wants to modify existing release directive + get_snowflake_facade().modify_release_directive( + package_name=self.name, + release_directive=release_directive, + release_channel=release_channel, + version=version, + patch=patch, + role=self.role, + ) + else: + get_snowflake_facade().set_release_directive( + package_name=self.name, + release_directive=release_directive, + release_channel=release_channel if available_release_channels else None, + target_accounts=target_accounts, + version=version, + patch=patch, + role=self.role, + ) + + def action_release_directive_unset( + self, action_ctx: ActionContext, release_directive: str, release_channel: str + ): + """ + Unsets a release directive from the specified release channel. + """ + if same_identifiers(release_directive, DEFAULT_DIRECTIVE): + raise ClickException( + "Cannot unset default release directive. Please specify a non-default release directive." + ) + + available_release_channels = get_snowflake_facade().show_release_channels( + self.name, self.role + ) + release_channel_names = [c.get("name") for c in available_release_channels] + if not same_identifiers( + release_channel, DEFAULT_CHANNEL + ) and not identifier_in_list(release_channel, release_channel_names): + raise ClickException( + f"Release channel {release_channel} does not exist in application package {self.name}." + ) + + get_snowflake_facade().unset_release_directive( + package_name=self.name, + release_directive=release_directive, + release_channel=release_channel if available_release_channels else None, + role=self.role, + ) + def _bundle(self): model = self._entity_model bundle_map = build_bundle(self.project_root, self.deploy_root, model.artifacts) @@ -664,7 +815,7 @@ def get_existing_release_directive_info_for_version( It executes a 'show release directives in application package' query and returns the filtered results, if they exist. """ release_directives = get_snowflake_facade().show_release_directives( - self.name, self.role + package_name=self.name, role=self.role ) return [ directive @@ -1060,7 +1211,7 @@ def resolve_version_info( # Check if patch needs to throw a bad option error, either if application package does not exist or if version does not exist if resolved_patch is not None: try: - if not self.get_existing_version_info(resolved_version): + if not self.get_existing_version_info(to_identifier(resolved_version)): raise BadOptionUsage( option_name="patch", message=f"Cannot create patch {resolved_patch} when version {resolved_version} is not defined in the application package {self.name}. Try again without specifying a patch.", diff --git a/src/snowflake/cli/_plugins/nativeapp/release_directive/__init__.py b/src/snowflake/cli/_plugins/nativeapp/release_directive/__init__.py new file mode 100644 index 0000000000..ada0a4e13d --- /dev/null +++ b/src/snowflake/cli/_plugins/nativeapp/release_directive/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2024 Snowflake Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/src/snowflake/cli/_plugins/nativeapp/release_directive/commands.py b/src/snowflake/cli/_plugins/nativeapp/release_directive/commands.py new file mode 100644 index 0000000000..2573bfdfe9 --- /dev/null +++ b/src/snowflake/cli/_plugins/nativeapp/release_directive/commands.py @@ -0,0 +1,165 @@ +# Copyright (c) 2024 Snowflake Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import logging +from typing import Optional + +import typer +from snowflake.cli._plugins.nativeapp.constants import DEFAULT_CHANNEL +from snowflake.cli._plugins.nativeapp.v2_conversions.compat import ( + force_project_definition_v2, +) +from snowflake.cli._plugins.workspace.manager import WorkspaceManager +from snowflake.cli.api.cli_global_context import get_cli_context +from snowflake.cli.api.commands.decorators import with_project_definition +from snowflake.cli.api.commands.flags import like_option +from snowflake.cli.api.commands.snow_typer import SnowTyperFactory +from snowflake.cli.api.entities.common import EntityActions +from snowflake.cli.api.output.types import ( + CollectionResult, + CommandResult, + MessageResult, +) + +app = SnowTyperFactory( + name="release-directive", + help="Manages release directives of an application package", +) + +log = logging.getLogger(__name__) + + +@app.command("list", requires_connection=True) +@with_project_definition() +@force_project_definition_v2() +def release_directive_list( + like: str = like_option( + help_example="`snow app release-directive list --like='my%'` lists all release directives starting with 'my'", + ), + channel: Optional[str] = typer.Option( + default=None, + show_default=False, + help="The release channel to use when listing release directives. If not provided, release directives from all release channels are listed.", + ), + **options, +) -> CommandResult: + """ + Lists release directives in an application package. + If no release channel is specified, release directives for all channels are listed. + If a release channel is specified, only release directives for that channel are listed. + + If `--like` is provided, only release directives matching the SQL pattern are listed. + """ + + cli_context = get_cli_context() + ws = WorkspaceManager( + project_definition=cli_context.project_definition, + project_root=cli_context.project_root, + ) + package_id = options["package_entity_id"] + result = ws.perform_action( + package_id, + EntityActions.RELEASE_DIRECTIVE_LIST, + release_channel=channel, + like=like, + ) + + return CollectionResult(result) + + +@app.command("set", requires_connection=True) +@with_project_definition() +@force_project_definition_v2() +def release_directive_set( + directive: str = typer.Argument( + show_default=False, + help="Name of the release directive to set", + ), + channel: str = typer.Option( + DEFAULT_CHANNEL, + help="Name of the release channel to use", + ), + target_accounts: Optional[list[str]] = typer.Option( + None, + show_default=False, + help="List of the accounts to apply the release directive to. Format has to be `org1.account1,org2.account2`", + ), + version: str = typer.Option( + show_default=False, + help="Version of the application package to use", + ), + patch: int = typer.Option( + show_default=False, + help="Patch number to use for the selected version", + ), + **options, +) -> CommandResult: + """ + Sets a release directive. + + target_accounts cannot be specified for default release directives. + target_accounts field is required when creating a new non-default release directive. + """ + + cli_context = get_cli_context() + ws = WorkspaceManager( + project_definition=cli_context.project_definition, + project_root=cli_context.project_root, + ) + package_id = options["package_entity_id"] + ws.perform_action( + package_id, + EntityActions.RELEASE_DIRECTIVE_SET, + release_directive=directive, + version=version, + patch=patch, + target_accounts=target_accounts, + release_channel=channel, + ) + return MessageResult("Successfully set release directive.") + + +@app.command("unset", requires_connection=True) +@with_project_definition() +@force_project_definition_v2() +def release_directive_unset( + directive: str = typer.Argument( + show_default=False, + help="Name of the release directive", + ), + channel: Optional[str] = typer.Option( + DEFAULT_CHANNEL, + help="Name of the release channel to use", + ), + **options, +) -> CommandResult: + """ + Unsets a release directive. + """ + + cli_context = get_cli_context() + ws = WorkspaceManager( + project_definition=cli_context.project_definition, + project_root=cli_context.project_root, + ) + package_id = options["package_entity_id"] + ws.perform_action( + package_id, + EntityActions.RELEASE_DIRECTIVE_UNSET, + release_directive=directive, + release_channel=channel, + ) + return MessageResult(f"Successfully unset release directive {directive}.") diff --git a/src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py b/src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py index e0fdbdb284..9bbed2b4cc 100644 --- a/src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py +++ b/src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py @@ -21,6 +21,7 @@ from snowflake.cli._plugins.connection.util import UIParameter, get_ui_parameter from snowflake.cli._plugins.nativeapp.constants import ( AUTHORIZE_TELEMETRY_COL, + DEFAULT_DIRECTIVE, NAME_COL, SPECIAL_COMMENT, ) @@ -42,20 +43,26 @@ from snowflake.cli.api.cli_global_context import get_cli_context from snowflake.cli.api.constants import ObjectType from snowflake.cli.api.errno import ( + ACCOUNT_DOES_NOT_EXIST, + ACCOUNT_HAS_TOO_MANY_QUALIFIERS, APPLICATION_REQUIRES_TELEMETRY_SHARING, CANNOT_DISABLE_MANDATORY_TELEMETRY, + CANNOT_DISABLE_RELEASE_CHANNELS, DOES_NOT_EXIST_OR_CANNOT_BE_PERFORMED, INSUFFICIENT_PRIVILEGES, NO_WAREHOUSE_SELECTED_IN_SESSION, + RELEASE_DIRECTIVE_DOES_NOT_EXIST, + RELEASE_DIRECTIVES_VERSION_PATCH_NOT_FOUND, + VERSION_DOES_NOT_EXIST, + VERSION_NOT_ADDED_TO_RELEASE_CHANNEL, ) from snowflake.cli.api.identifiers import FQN from snowflake.cli.api.metrics import CLICounterField from snowflake.cli.api.project.schemas.v1.native_app.package import DistributionOptions from snowflake.cli.api.project.util import ( identifier_to_show_like_pattern, - is_valid_unquoted_identifier, + same_identifiers, to_identifier, - to_quoted_identifier, to_string_literal, ) from snowflake.cli.api.sql_execution import BaseSqlExecutor @@ -111,7 +118,7 @@ def _use_object_optional(self, object_type: UseObjectType, name: str | None): except IndexError: prev_obj = None - if prev_obj is not None and _same_identifier(prev_obj, name): + if prev_obj is not None and same_identifiers(prev_obj, name): yield return @@ -528,7 +535,10 @@ def create_stage( handle_unclassified_error(err, f"Failed to create stage {name}.") def show_release_directives( - self, package_name: str, role: str | None = None + self, + package_name: str, + release_channel: str | None = None, + role: str | None = None, ) -> list[dict[str, Any]]: """ Show release directives for a package @@ -536,10 +546,15 @@ def show_release_directives( @param [Optional] role: Role to switch to while running this script. Current role will be used if no role is passed in. """ package_identifier = to_identifier(package_name) + + query = f"show release directives in application package {package_identifier}" + if release_channel: + query += f" for release channel {to_identifier(release_channel)}" + with self._use_role_optional(role): try: cursor = self._sql_executor.execute_query( - f"show release directives in application package {package_identifier}", + query, cursor_class=DictCursor, ) except ProgrammingError as err: @@ -569,7 +584,7 @@ def get_existing_app_info(self, name: str, role: str) -> dict | None: ) show_obj_row = find_first_row( - show_obj_cursor, lambda row: _same_identifier(row[NAME_COL], name) + show_obj_cursor, lambda row: same_identifiers(row[NAME_COL], name) ) except Exception as err: handle_unclassified_error( @@ -822,6 +837,10 @@ def alter_application_package_properties( f"Insufficient privileges update enable_release_channels for application package {package_name}", role=role, ) from err + if err.errno == CANNOT_DISABLE_RELEASE_CHANNELS: + raise UserInputError( + f"Cannot disable release channels for application package {package_name} after it is enabled. Try recreating the application package." + ) from err handle_unclassified_error( err, f"Failed to update enable_release_channels for application package {package_name}.", @@ -839,26 +858,238 @@ def get_ui_parameter(self, parameter: UIParameter, default: Any) -> Any: return get_ui_parameter(connection, parameter, default) + def set_release_directive( + self, + package_name: str, + release_directive: str, + release_channel: str | None, + target_accounts: List[str] | None, + version: str, + patch: int, + role: str | None = None, + ): + """ + Sets a release directive for an application package. + Default release directive does not support target accounts. + Non-default release directives require target accounts to be specified. -# TODO move this to src/snowflake/cli/api/project/util.py in a separate -# PR since it's codeowned by the CLI team -def _same_identifier(id1: str, id2: str) -> bool: - """ - Returns whether two identifiers refer to the same object. + @param package_name: Name of the application package to alter. + @param release_directive: Name of the release directive to set. + @param release_channel: Name of the release channel to set the release directive for. + @param target_accounts: List of target accounts for the release directive. + @param version: Version to set the release directive for. + @param patch: Patch number to set the release directive for. + @param [Optional] role: Role to switch to while running this script. Current role will be used if no role is passed in. + """ - Two unquoted identifiers are considered the same if they are equal when both are converted to uppercase - Two quoted identifiers are considered the same if they are exactly equal - An unquoted identifier and a quoted identifier are considered the same - if the quoted identifier is equal to the unquoted identifier - when the unquoted identifier is converted to uppercase and quoted - """ - # Canonicalize the identifiers by converting unquoted identifiers to uppercase and leaving quoted identifiers as is - canonical_id1 = id1.upper() if is_valid_unquoted_identifier(id1) else id1 - canonical_id2 = id2.upper() if is_valid_unquoted_identifier(id2) else id2 + if same_identifiers(release_directive, DEFAULT_DIRECTIVE) and target_accounts: + raise UserInputError( + "Default release directive does not support target accounts." + ) + + if ( + not same_identifiers(release_directive, DEFAULT_DIRECTIVE) + and not target_accounts + ): + raise UserInputError( + "Non-default release directives require target accounts to be specified." + ) + + package_name = to_identifier(package_name) + release_channel = to_identifier(release_channel) if release_channel else None + release_directive = to_identifier(release_directive) + version = to_identifier(version) + + release_directive_statement = ( + "set default release directive" + if same_identifiers(release_directive, DEFAULT_DIRECTIVE) + else f"set release directive {release_directive}" + ) + + release_channel_statement = ( + f"modify release channel {release_channel}" if release_channel else "" + ) - # The canonical identifiers are equal if they are equal when both are quoted - # (if they are already quoted, this is a no-op) - return to_quoted_identifier(canonical_id1) == to_quoted_identifier(canonical_id2) + accounts_statement = ( + f"accounts = ({','.join(target_accounts)})" if target_accounts else "" + ) + + full_query = dedent( + _strip_empty_lines( + f"""\ + alter application package {package_name} + {release_channel_statement} + {release_directive_statement} + {accounts_statement} + version = {version} patch = {patch} + """ + ) + ) + + with self._use_role_optional(role): + try: + self._sql_executor.execute_query(full_query) + except ProgrammingError as err: + if ( + err.errno == ACCOUNT_DOES_NOT_EXIST + or err.errno == ACCOUNT_HAS_TOO_MANY_QUALIFIERS + ): + raise UserInputError( + f"Invalid account passed in.\n{str(err.msg)}" + ) from err + _handle_release_directive_version_error( + err, + package_name=package_name, + release_channel=release_channel, + version=version, + patch=patch, + ) + handle_unclassified_error( + err, + f"Failed to set release directive {release_directive} for package {package_name}.", + ) + + def modify_release_directive( + self, + package_name: str, + release_directive: str, + release_channel: str | None, + version: str, + patch: int, + role: str | None = None, + ): + """ + Modifies a release directive for an application package. + Release directive must already exist in the application package. + Accepts both default and non-default release directives. + + @param package_name: Name of the application package to alter. + @param release_directive: Name of the release directive to modify. + @param release_channel: Name of the release channel to modify the release directive for. + @param version: Version to modify the release directive for. + @param patch: Patch number to modify the release directive for. + @param [Optional] role: Role to switch to while running this script. Current role will be used if no role is passed in. + """ + + package_name = to_identifier(package_name) + release_channel = to_identifier(release_channel) if release_channel else None + release_directive = to_identifier(release_directive) + version = to_identifier(version) + + release_directive_statement = ( + "modify default release directive" + if same_identifiers(release_directive, DEFAULT_DIRECTIVE) + else f"modify release directive {release_directive}" + ) + + release_channel_statement = ( + f"modify release channel {release_channel}" if release_channel else "" + ) + + full_query = dedent( + _strip_empty_lines( + f"""\ + alter application package {package_name} + {release_channel_statement} + {release_directive_statement} + version = {version} patch = {patch} + """ + ) + ) + + with self._use_role_optional(role): + try: + self._sql_executor.execute_query(full_query) + except ProgrammingError as err: + if err.errno == RELEASE_DIRECTIVE_DOES_NOT_EXIST: + raise UserInputError( + f"Release directive {release_directive} does not exist in application package {package_name}. Please create it first by specifying the target accounts." + ) from err + _handle_release_directive_version_error( + err, + package_name=package_name, + release_channel=release_channel, + version=version, + patch=patch, + ) + handle_unclassified_error( + err, + f"Failed to modify release directive {release_directive} for package {package_name}.", + ) + + def unset_release_directive( + self, + package_name: str, + release_directive: str, + release_channel: str | None, + role: str | None = None, + ): + """ + Unsets a release directive for an application package. + Release directive must already exist in the application package. + Does not accept default release directive. + + @param package_name: Name of the application package to alter. + @param release_directive: Name of the release directive to unset. + @param release_channel: Name of the release channel to unset the release directive for. + @param [Optional] role: Role to switch to while running this script. Current role will be used if no role is passed in. + """ + package_name = to_identifier(package_name) + release_channel = to_identifier(release_channel) if release_channel else None + release_directive = to_identifier(release_directive) + + if same_identifiers(release_directive, DEFAULT_DIRECTIVE): + raise UserInputError( + "Cannot unset default release directive. Please specify a non-default release directive." + ) + + release_channel_statement = "" + if release_channel: + release_channel_statement = f" modify release channel {release_channel}" + + with self._use_role_optional(role): + try: + self._sql_executor.execute_query( + f"alter application package {package_name}{release_channel_statement} unset release directive {release_directive}" + ) + except ProgrammingError as err: + if err.errno == RELEASE_DIRECTIVE_DOES_NOT_EXIST: + raise UserInputError( + f"Release directive {release_directive} does not exist in application package {package_name}." + ) from err + handle_unclassified_error( + err, + f"Failed to unset release directive {release_directive} for package {package_name}.", + ) + + def show_release_channels( + self, package_name: str, role: str | None = None + ) -> list[dict[str, Any]]: + """ + Show release channels in a package. + @param package_name: Name of the package + @param [Optional] role: Role to switch to while running this script. Current role will be used if no role is passed in. + """ + + if ( + self.get_ui_parameter(UIParameter.NA_FEATURE_RELEASE_CHANNELS, True) + is False + ): + return [] + + package_identifier = to_identifier(package_name) + with self._use_role_optional(role): + try: + cursor = self._sql_executor.execute_query( + f"show release channels in application package {package_identifier}", + cursor_class=DictCursor, + ) + except ProgrammingError as err: + handle_unclassified_error( + err, + f"Failed to show release channels for application package {package_name}.", + ) + return cursor.fetchall() def _strip_empty_lines(text: str) -> str: @@ -866,3 +1097,26 @@ def _strip_empty_lines(text: str) -> str: Strips empty lines from the input string. """ return "\n".join(line for line in text.splitlines() if line.strip()) + + +def _handle_release_directive_version_error( + err: ProgrammingError, + *, + package_name: str, + release_channel: str | None, + version: str, + patch: int, +) -> None: + + if err.errno == VERSION_NOT_ADDED_TO_RELEASE_CHANNEL: + raise UserInputError( + f"Version {version} is not added to release channel {release_channel}. Please add it to the release channel first." + ) from err + if err.errno == RELEASE_DIRECTIVES_VERSION_PATCH_NOT_FOUND: + raise UserInputError( + f"Patch {patch} for version {version} not found in application package {package_name}." + ) from err + if err.errno == VERSION_DOES_NOT_EXIST: + raise UserInputError( + f"Version {version} does not exist in application package {package_name}." + ) from err diff --git a/src/snowflake/cli/api/entities/common.py b/src/snowflake/cli/api/entities/common.py index 021830fcb2..c7bd6bfb0f 100644 --- a/src/snowflake/cli/api/entities/common.py +++ b/src/snowflake/cli/api/entities/common.py @@ -17,6 +17,14 @@ class EntityActions(str, Enum): VERSION_CREATE = "action_version_create" VERSION_DROP = "action_version_drop" + RELEASE_DIRECTIVE_UNSET = "action_release_directive_unset" + RELEASE_DIRECTIVE_SET = "action_release_directive_set" + RELEASE_DIRECTIVE_LIST = "action_release_directive_list" + + RELEASE_CHANNEL_LIST = "action_release_channel_list" + RELEASE_CHANNEL_ADD_VERSION = "action_release_channel_add_version" + RELEASE_CHANNEL_REMOVE_VERSION = "action_release_channel_remove_version" + T = TypeVar("T") @@ -30,7 +38,7 @@ def attach_spans_to_entity_actions(entity_name: str): entity_name (str): Custom name for entity type to be displayed in metrics """ - def decorator(cls: type[EntityBase]) -> type[EntityBase]: + def decorator(cls: type[T]) -> type[T]: for attr_name, attr_value in vars(cls).items(): is_entity_action = attr_name in [ enum_member for enum_member in EntityActions diff --git a/src/snowflake/cli/api/errno.py b/src/snowflake/cli/api/errno.py index c49fd167fa..88796f2590 100644 --- a/src/snowflake/cli/api/errno.py +++ b/src/snowflake/cli/api/errno.py @@ -61,6 +61,13 @@ NATIVE_APPLICATION_MANIFEST_INVALID_SYNTAX = 93300 APPLICATION_REQUIRES_TELEMETRY_SHARING = 93321 CANNOT_DISABLE_MANDATORY_TELEMETRY = 93329 +VERSION_NOT_ADDED_TO_RELEASE_CHANNEL = 512008 +CANNOT_DISABLE_RELEASE_CHANNELS = 512001 +RELEASE_DIRECTIVES_VERSION_PATCH_NOT_FOUND = 93036 +RELEASE_DIRECTIVE_DOES_NOT_EXIST = 93090 +VERSION_DOES_NOT_EXIST = 93031 +ACCOUNT_DOES_NOT_EXIST = 1999 +ACCOUNT_HAS_TOO_MANY_QUALIFIERS = 906 ERR_JAVASCRIPT_EXECUTION = 100132 diff --git a/src/snowflake/cli/api/project/util.py b/src/snowflake/cli/api/project/util.py index 9564e2bae2..58824ae58b 100644 --- a/src/snowflake/cli/api/project/util.py +++ b/src/snowflake/cli/api/project/util.py @@ -280,3 +280,44 @@ def append_test_resource_suffix(identifier: str) -> str: # Otherwise just append the string, don't add quotes # in case the user doesn't want them return f"{identifier}{suffix}" + + +def same_identifiers(id1: str, id2: str) -> bool: + """ + Returns whether two identifiers refer to the same object. + + Two unquoted identifiers are considered the same if they are equal when both are converted to uppercase + Two quoted identifiers are considered the same if they are exactly equal + An unquoted identifier and a quoted identifier are considered the same + if the quoted identifier is equal to the uppercase version of the unquoted identifier + """ + # Canonicalize the identifiers by converting unquoted identifiers to uppercase and leaving quoted identifiers as is + canonical_id1 = id1.upper() if is_valid_unquoted_identifier(id1) else id1 + canonical_id2 = id2.upper() if is_valid_unquoted_identifier(id2) else id2 + + # The canonical identifiers are equal if they are equal when both are quoted + # (if they are already quoted, this is a no-op) + return to_quoted_identifier(canonical_id1) == to_quoted_identifier(canonical_id2) + + +def sql_match(*, pattern: str, value: str) -> bool: + """ + Returns whether the value matches the pattern when used with LIKE in Snowflake. + Compares the 2 input and ignores the case. + """ + value = unquote_identifier(value) + return ( + re.fullmatch( + pattern.replace(r"%", ".*").replace(r"_", "."), value, re.IGNORECASE + ) + is not None + ) + + +def identifier_in_list(identifier: str, identifier_list: list[str]) -> bool: + """ + Returns whether the identifier is in the list of identifiers. + """ + return any( + same_identifiers(identifier, id_from_list) for id_from_list in identifier_list + ) diff --git a/tests/__snapshots__/test_help_messages.ambr b/tests/__snapshots__/test_help_messages.ambr index 1f16f68e45..f78ec957d5 100644 --- a/tests/__snapshots__/test_help_messages.ambr +++ b/tests/__snapshots__/test_help_messages.ambr @@ -578,6 +578,355 @@ +------------------------------------------------------------------------------+ + ''' +# --- +# name: test_help_messages[app.release-directive.list] + ''' + + Usage: default app release-directive list [OPTIONS] + + Lists release directives in an application package. If no release channel is + specified, release directives for all channels are listed. If a release + channel is specified, only release directives for that channel are listed. + If --like is provided, only release directives matching the SQL pattern are + listed. + + +- Options --------------------------------------------------------------------+ + | --like -l TEXT SQL LIKE pattern for filtering objects by | + | name. For example, snow app | + | release-directive list --like='my%' lists | + | all release directives starting with | + | 'my'. | + | [default: %%] | + | --channel TEXT The release channel to use when listing | + | release directives. If not provided, | + | release directives from all release | + | channels are listed. | + | --package-entity-id TEXT The ID of the package entity on which to | + | operate when definition_version is 2 or | + | higher. | + | --app-entity-id TEXT The ID of the application entity on which | + | to operate when definition_version is 2 | + | or higher. | + | --project -p TEXT Path where Snowflake project resides. | + | Defaults to current working directory. | + | --env TEXT String in format of key=value. Overrides | + | variables from env section used for | + | templates. | + | --help -h Show this message and exit. | + +------------------------------------------------------------------------------+ + +- Connection configuration ---------------------------------------------------+ + | --connection,--environment -c TEXT Name of the connection, as | + | defined in your config.toml | + | file. Default: default. | + | --host TEXT Host address for the | + | connection. Overrides the | + | value specified for the | + | connection. | + | --port INTEGER Port for the connection. | + | Overrides the value | + | specified for the | + | connection. | + | --account,--accountname TEXT Name assigned to your | + | Snowflake account. Overrides | + | the value specified for the | + | connection. | + | --user,--username TEXT Username to connect to | + | Snowflake. Overrides the | + | value specified for the | + | connection. | + | --password TEXT Snowflake password. | + | Overrides the value | + | specified for the | + | connection. | + | --authenticator TEXT Snowflake authenticator. | + | Overrides the value | + | specified for the | + | connection. | + | --private-key-file,--privateā€¦ TEXT Snowflake private key file | + | path. Overrides the value | + | specified for the | + | connection. | + | --token-file-path TEXT Path to file with an OAuth | + | token that should be used | + | when connecting to Snowflake | + | --database,--dbname TEXT Database to use. Overrides | + | the value specified for the | + | connection. | + | --schema,--schemaname TEXT Database schema to use. | + | Overrides the value | + | specified for the | + | connection. | + | --role,--rolename TEXT Role to use. Overrides the | + | value specified for the | + | connection. | + | --warehouse TEXT Warehouse to use. Overrides | + | the value specified for the | + | connection. | + | --temporary-connection -x Uses connection defined with | + | command line parameters, | + | instead of one defined in | + | config | + | --mfa-passcode TEXT Token to use for | + | multi-factor authentication | + | (MFA) | + | --enable-diag Run Python connector | + | diagnostic test | + | --diag-log-path TEXT Diagnostic report path | + | --diag-allowlist-path TEXT Diagnostic report path to | + | optional allowlist | + +------------------------------------------------------------------------------+ + +- Global configuration -------------------------------------------------------+ + | --format [TABLE|JSON] Specifies the output format. | + | [default: TABLE] | + | --verbose -v Displays log entries for log levels info | + | and higher. | + | --debug Displays log entries for log levels debug | + | and higher; debug logs contain additional | + | information. | + | --silent Turns off intermediate output to console. | + +------------------------------------------------------------------------------+ + + + ''' +# --- +# name: test_help_messages[app.release-directive.set] + ''' + + Usage: default app release-directive set [OPTIONS] DIRECTIVE + + Sets a release directive. + target_accounts cannot be specified for default release directives. + target_accounts field is required when creating a new non-default release + directive. + + +- Arguments ------------------------------------------------------------------+ + | * directive TEXT Name of the release directive to set | + | [required] | + +------------------------------------------------------------------------------+ + +- Options --------------------------------------------------------------------+ + | --channel TEXT Name of the release channel to use | + | [default: DEFAULT] | + | --target-accounts TEXT List of the accounts to apply the | + | release directive to. Format has to | + | be org1.account1,org2.account2 | + | * --version TEXT Version of the application package | + | to use | + | [required] | + | * --patch INTEGER Patch number to use for the | + | selected version | + | [required] | + | --package-entity-id TEXT The ID of the package entity on | + | which to operate when | + | definition_version is 2 or higher. | + | --app-entity-id TEXT The ID of the application entity on | + | which to operate when | + | definition_version is 2 or higher. | + | --project -p TEXT Path where Snowflake project | + | resides. Defaults to current | + | working directory. | + | --env TEXT String in format of key=value. | + | Overrides variables from env | + | section used for templates. | + | --help -h Show this message and exit. | + +------------------------------------------------------------------------------+ + +- Connection configuration ---------------------------------------------------+ + | --connection,--environment -c TEXT Name of the connection, as | + | defined in your config.toml | + | file. Default: default. | + | --host TEXT Host address for the | + | connection. Overrides the | + | value specified for the | + | connection. | + | --port INTEGER Port for the connection. | + | Overrides the value | + | specified for the | + | connection. | + | --account,--accountname TEXT Name assigned to your | + | Snowflake account. Overrides | + | the value specified for the | + | connection. | + | --user,--username TEXT Username to connect to | + | Snowflake. Overrides the | + | value specified for the | + | connection. | + | --password TEXT Snowflake password. | + | Overrides the value | + | specified for the | + | connection. | + | --authenticator TEXT Snowflake authenticator. | + | Overrides the value | + | specified for the | + | connection. | + | --private-key-file,--privateā€¦ TEXT Snowflake private key file | + | path. Overrides the value | + | specified for the | + | connection. | + | --token-file-path TEXT Path to file with an OAuth | + | token that should be used | + | when connecting to Snowflake | + | --database,--dbname TEXT Database to use. Overrides | + | the value specified for the | + | connection. | + | --schema,--schemaname TEXT Database schema to use. | + | Overrides the value | + | specified for the | + | connection. | + | --role,--rolename TEXT Role to use. Overrides the | + | value specified for the | + | connection. | + | --warehouse TEXT Warehouse to use. Overrides | + | the value specified for the | + | connection. | + | --temporary-connection -x Uses connection defined with | + | command line parameters, | + | instead of one defined in | + | config | + | --mfa-passcode TEXT Token to use for | + | multi-factor authentication | + | (MFA) | + | --enable-diag Run Python connector | + | diagnostic test | + | --diag-log-path TEXT Diagnostic report path | + | --diag-allowlist-path TEXT Diagnostic report path to | + | optional allowlist | + +------------------------------------------------------------------------------+ + +- Global configuration -------------------------------------------------------+ + | --format [TABLE|JSON] Specifies the output format. | + | [default: TABLE] | + | --verbose -v Displays log entries for log levels info | + | and higher. | + | --debug Displays log entries for log levels debug | + | and higher; debug logs contain additional | + | information. | + | --silent Turns off intermediate output to console. | + +------------------------------------------------------------------------------+ + + + ''' +# --- +# name: test_help_messages[app.release-directive.unset] + ''' + + Usage: default app release-directive unset [OPTIONS] DIRECTIVE + + Unsets a release directive. + + +- Arguments ------------------------------------------------------------------+ + | * directive TEXT Name of the release directive | + | [required] | + +------------------------------------------------------------------------------+ + +- Options --------------------------------------------------------------------+ + | --channel TEXT Name of the release channel to use | + | [default: DEFAULT] | + | --package-entity-id TEXT The ID of the package entity on which to | + | operate when definition_version is 2 or | + | higher. | + | --app-entity-id TEXT The ID of the application entity on which | + | to operate when definition_version is 2 | + | or higher. | + | --project -p TEXT Path where Snowflake project resides. | + | Defaults to current working directory. | + | --env TEXT String in format of key=value. Overrides | + | variables from env section used for | + | templates. | + | --help -h Show this message and exit. | + +------------------------------------------------------------------------------+ + +- Connection configuration ---------------------------------------------------+ + | --connection,--environment -c TEXT Name of the connection, as | + | defined in your config.toml | + | file. Default: default. | + | --host TEXT Host address for the | + | connection. Overrides the | + | value specified for the | + | connection. | + | --port INTEGER Port for the connection. | + | Overrides the value | + | specified for the | + | connection. | + | --account,--accountname TEXT Name assigned to your | + | Snowflake account. Overrides | + | the value specified for the | + | connection. | + | --user,--username TEXT Username to connect to | + | Snowflake. Overrides the | + | value specified for the | + | connection. | + | --password TEXT Snowflake password. | + | Overrides the value | + | specified for the | + | connection. | + | --authenticator TEXT Snowflake authenticator. | + | Overrides the value | + | specified for the | + | connection. | + | --private-key-file,--privateā€¦ TEXT Snowflake private key file | + | path. Overrides the value | + | specified for the | + | connection. | + | --token-file-path TEXT Path to file with an OAuth | + | token that should be used | + | when connecting to Snowflake | + | --database,--dbname TEXT Database to use. Overrides | + | the value specified for the | + | connection. | + | --schema,--schemaname TEXT Database schema to use. | + | Overrides the value | + | specified for the | + | connection. | + | --role,--rolename TEXT Role to use. Overrides the | + | value specified for the | + | connection. | + | --warehouse TEXT Warehouse to use. Overrides | + | the value specified for the | + | connection. | + | --temporary-connection -x Uses connection defined with | + | command line parameters, | + | instead of one defined in | + | config | + | --mfa-passcode TEXT Token to use for | + | multi-factor authentication | + | (MFA) | + | --enable-diag Run Python connector | + | diagnostic test | + | --diag-log-path TEXT Diagnostic report path | + | --diag-allowlist-path TEXT Diagnostic report path to | + | optional allowlist | + +------------------------------------------------------------------------------+ + +- Global configuration -------------------------------------------------------+ + | --format [TABLE|JSON] Specifies the output format. | + | [default: TABLE] | + | --verbose -v Displays log entries for log levels info | + | and higher. | + | --debug Displays log entries for log levels debug | + | and higher; debug logs contain additional | + | information. | + | --silent Turns off intermediate output to console. | + +------------------------------------------------------------------------------+ + + + ''' +# --- +# name: test_help_messages[app.release-directive] + ''' + + Usage: default app release-directive [OPTIONS] COMMAND [ARGS]... + + Manages release directives of an application package + + +- Options --------------------------------------------------------------------+ + | --help -h Show this message and exit. | + +------------------------------------------------------------------------------+ + +- Commands -------------------------------------------------------------------+ + | list Lists release directives in an application package. If no release | + | channel is specified, release directives for all channels are | + | listed. If a release channel is specified, only release directives | + | for that channel are listed. | + | set Sets a release directive. | + | unset Unsets a release directive. | + +------------------------------------------------------------------------------+ + + ''' # --- # name: test_help_messages[app.run] @@ -1406,23 +1755,27 @@ | --help -h Show this message and exit. | +------------------------------------------------------------------------------+ +- Commands -------------------------------------------------------------------+ - | bundle Prepares a local folder with configured app artifacts. | - | deploy Creates an application package in your Snowflake account and | - | syncs the local changes to the stage without creating or updating | - | the application. Running this command with no arguments at all, | - | as in snow app deploy, is a shorthand for snow app deploy --prune | - | --recursive. | - | events Fetches events for this app from the event table configured in | - | Snowflake. | - | open Opens the Snowflake Native App inside of your browser, once it | - | has been installed in your account. | - | run Creates an application package in your Snowflake account, uploads | - | code files to its stage, then creates or upgrades an application | - | object from the application package. | - | teardown Attempts to drop both the application object and application | - | package as defined in the project definition file. | - | validate Validates a deployed Snowflake Native App's setup script. | - | version Manages versions defined in an application package | + | bundle Prepares a local folder with configured app artifacts. | + | deploy Creates an application package in your Snowflake account | + | and syncs the local changes to the stage without | + | creating or updating the application. Running this | + | command with no arguments at all, as in snow app deploy, | + | is a shorthand for snow app deploy --prune --recursive. | + | events Fetches events for this app from the event table | + | configured in Snowflake. | + | open Opens the Snowflake Native App inside of your browser, | + | once it has been installed in your account. | + | release-directive Manages release directives of an application package | + | run Creates an application package in your Snowflake | + | account, uploads code files to its stage, then creates | + | or upgrades an application object from the application | + | package. | + | teardown Attempts to drop both the application object and | + | application package as defined in the project definition | + | file. | + | validate Validates a deployed Snowflake Native App's setup | + | script. | + | version Manages versions defined in an application package | +------------------------------------------------------------------------------+ @@ -9757,6 +10110,28 @@ +------------------------------------------------------------------------------+ + ''' +# --- +# name: test_help_messages_no_help_flag[app.release-directive] + ''' + + Usage: default app release-directive [OPTIONS] COMMAND [ARGS]... + + Manages release directives of an application package + + +- Options --------------------------------------------------------------------+ + | --help -h Show this message and exit. | + +------------------------------------------------------------------------------+ + +- Commands -------------------------------------------------------------------+ + | list Lists release directives in an application package. If no release | + | channel is specified, release directives for all channels are | + | listed. If a release channel is specified, only release directives | + | for that channel are listed. | + | set Sets a release directive. | + | unset Unsets a release directive. | + +------------------------------------------------------------------------------+ + + ''' # --- # name: test_help_messages_no_help_flag[app.version] @@ -9793,23 +10168,27 @@ | --help -h Show this message and exit. | +------------------------------------------------------------------------------+ +- Commands -------------------------------------------------------------------+ - | bundle Prepares a local folder with configured app artifacts. | - | deploy Creates an application package in your Snowflake account and | - | syncs the local changes to the stage without creating or updating | - | the application. Running this command with no arguments at all, | - | as in snow app deploy, is a shorthand for snow app deploy --prune | - | --recursive. | - | events Fetches events for this app from the event table configured in | - | Snowflake. | - | open Opens the Snowflake Native App inside of your browser, once it | - | has been installed in your account. | - | run Creates an application package in your Snowflake account, uploads | - | code files to its stage, then creates or upgrades an application | - | object from the application package. | - | teardown Attempts to drop both the application object and application | - | package as defined in the project definition file. | - | validate Validates a deployed Snowflake Native App's setup script. | - | version Manages versions defined in an application package | + | bundle Prepares a local folder with configured app artifacts. | + | deploy Creates an application package in your Snowflake account | + | and syncs the local changes to the stage without | + | creating or updating the application. Running this | + | command with no arguments at all, as in snow app deploy, | + | is a shorthand for snow app deploy --prune --recursive. | + | events Fetches events for this app from the event table | + | configured in Snowflake. | + | open Opens the Snowflake Native App inside of your browser, | + | once it has been installed in your account. | + | release-directive Manages release directives of an application package | + | run Creates an application package in your Snowflake | + | account, uploads code files to its stage, then creates | + | or upgrades an application object from the application | + | package. | + | teardown Attempts to drop both the application object and | + | application package as defined in the project definition | + | file. | + | validate Validates a deployed Snowflake Native App's setup | + | script. | + | version Manages versions defined in an application package | +------------------------------------------------------------------------------+ diff --git a/tests/nativeapp/factories.py b/tests/nativeapp/factories.py index d0c67f0601..8a4c51c5e7 100644 --- a/tests/nativeapp/factories.py +++ b/tests/nativeapp/factories.py @@ -355,7 +355,6 @@ class ManifestFactory(factory.DictFactory): { "log_level": "fatal", "trace_level": "always", - "telemetry_event_definitions": None, } ) diff --git a/tests/nativeapp/fixtures.py b/tests/nativeapp/fixtures.py index f137bb9491..390898d5f2 100644 --- a/tests/nativeapp/fixtures.py +++ b/tests/nativeapp/fixtures.py @@ -38,7 +38,7 @@ def mock_bundle_map(): @pytest.fixture() -def application_package_entity(workspace_context): +def application_package_entity(workspace_context) -> ApplicationPackageEntity: data = ApplicationPackageEntityModelFactory(identifier=factory.Faker("word")) model = ApplicationPackageEntityModel(**data) return ApplicationPackageEntity(model, workspace_context) diff --git a/tests/nativeapp/test_application_package_entity.py b/tests/nativeapp/test_application_package_entity.py index d07683ac94..2a0e632a6d 100644 --- a/tests/nativeapp/test_application_package_entity.py +++ b/tests/nativeapp/test_application_package_entity.py @@ -16,7 +16,9 @@ from pathlib import Path from unittest import mock +import pytest import yaml +from click import ClickException from snowflake.cli._plugins.connection.util import UIParameter from snowflake.cli._plugins.nativeapp.constants import ( LOOSE_FILES_MAGIC_VERSION, @@ -34,6 +36,11 @@ APPLICATION_PACKAGE_ENTITY_MODULE, SQL_EXECUTOR_EXECUTE, SQL_FACADE_GET_UI_PARAMETER, + SQL_FACADE_MODIFY_RELEASE_DIRECTIVE, + SQL_FACADE_SET_RELEASE_DIRECTIVE, + SQL_FACADE_SHOW_RELEASE_CHANNELS, + SQL_FACADE_SHOW_RELEASE_DIRECTIVES, + SQL_FACADE_UNSET_RELEASE_DIRECTIVE, mock_execute_helper, ) @@ -197,3 +204,647 @@ def test_version_list( mock_execute.side_effect = side_effects application_package_entity.action_version_list(action_context) assert mock_execute.mock_calls == expected + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[]) +@mock.patch(SQL_FACADE_SHOW_RELEASE_DIRECTIVES, return_value=[]) +def test_given_channels_disabled_and_no_directives_when_release_directive_list_then_success( + show_release_directives, + show_release_channels, + application_package_entity, + action_context, +): + + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + result = application_package_entity.action_release_directive_list( + action_ctx=action_context, release_channel=None, like="%%" + ) + + assert result == [] + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + show_release_directives.assert_called_once_with( + package_name=pkg_model.fqn.name, + role=pkg_model.meta.role, + release_channel=None, + ) + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[]) +@mock.patch(SQL_FACADE_SHOW_RELEASE_DIRECTIVES, return_value=[{"name": "my_directive"}]) +def test_given_channels_disabled_and_directives_present_when_release_directive_list_then_success( + show_release_directives, + show_release_channels, + application_package_entity, + action_context, +): + + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + result = application_package_entity.action_release_directive_list( + action_ctx=action_context, release_channel=None, like="%%" + ) + + assert result == [{"name": "my_directive"}] + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + show_release_directives.assert_called_once_with( + package_name=pkg_model.fqn.name, + role=pkg_model.meta.role, + release_channel=None, + ) + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[]) +@mock.patch( + SQL_FACADE_SHOW_RELEASE_DIRECTIVES, + return_value=[{"name": "abcdef"}, {"name": "ghijkl"}], +) +def test_given_multiple_directives_and_like_pattern_when_release_directive_list_then_filter_results( + show_release_directives, + show_release_channels, + application_package_entity, + action_context, +): + + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + result = application_package_entity.action_release_directive_list( + action_ctx=action_context, release_channel=None, like="abc%" + ) + + assert result == [{"name": "abcdef"}] + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + show_release_directives.assert_called_once_with( + package_name=pkg_model.fqn.name, + role=pkg_model.meta.role, + release_channel=None, + ) + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[{"name": "my_channel"}]) +@mock.patch(SQL_FACADE_SHOW_RELEASE_DIRECTIVES, return_value=[{"name": "my_directive"}]) +def test_given_channels_enabled_and_no_channel_specified_when_release_directive_list_then_success( + show_release_directives, + show_release_channels, + application_package_entity, + action_context, +): + + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + result = application_package_entity.action_release_directive_list( + action_ctx=action_context, release_channel=None, like="%%" + ) + + assert result == [{"name": "my_directive"}] + + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + show_release_directives.assert_called_once_with( + package_name=pkg_model.fqn.name, + role=pkg_model.meta.role, + release_channel=None, + ) + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[]) +@mock.patch(SQL_FACADE_SHOW_RELEASE_DIRECTIVES, return_value=[{"name": "my_directive"}]) +def test_given_channels_disabled_and_default_channel_selected_when_release_directive_list_then_ignore_channel( + show_release_directives, + show_release_channels, + application_package_entity, + action_context, +): + + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + result = application_package_entity.action_release_directive_list( + action_ctx=action_context, release_channel="default", like="%%" + ) + + assert result == [{"name": "my_directive"}] + + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + show_release_directives.assert_called_once_with( + package_name=pkg_model.fqn.name, + role=pkg_model.meta.role, + release_channel=None, + ) + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[]) +@mock.patch(SQL_FACADE_SHOW_RELEASE_DIRECTIVES, return_value=[{"name": "my_directive"}]) +def test_given_channels_disabled_and_non_default_channel_selected_when_release_directive_list_then_error( + show_release_directives, + show_release_channels, + application_package_entity, + action_context, +): + + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + with pytest.raises(ClickException) as e: + application_package_entity.action_release_directive_list( + action_ctx=action_context, release_channel="non_default", like="%%" + ) + + assert ( + str(e.value) + == f"Release channel non_default does not exist in application package {pkg_model.fqn.name}." + ) + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + show_release_directives.assert_not_called() + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[{"name": "my_channel"}]) +@mock.patch(SQL_FACADE_SHOW_RELEASE_DIRECTIVES, return_value=[{"name": "my_directive"}]) +def test_given_channels_enabled_and_invalid_channel_selected_when_release_directive_list_then_error( + show_release_directives, + show_release_channels, + application_package_entity, + action_context, +): + + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + with pytest.raises(ClickException) as e: + application_package_entity.action_release_directive_list( + action_ctx=action_context, release_channel="invalid_channel", like="%%" + ) + + assert ( + str(e.value) + == f"Release channel invalid_channel does not exist in application package {pkg_model.fqn.name}." + ) + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + show_release_directives.assert_not_called() + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[{"name": "my_channel"}]) +@mock.patch(SQL_FACADE_SHOW_RELEASE_DIRECTIVES, return_value=[{"name": "my_directive"}]) +def test_given_channels_enabled_and_valid_channel_selected_when_release_directive_list_then_success( + show_release_directives, + show_release_channels, + application_package_entity, + action_context, +): + + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + result = application_package_entity.action_release_directive_list( + action_ctx=action_context, release_channel="my_channel", like="%%" + ) + + assert result == [{"name": "my_directive"}] + + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + show_release_directives.assert_called_once_with( + package_name=pkg_model.fqn.name, + role=pkg_model.meta.role, + release_channel="my_channel", + ) + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[{"name": "test_channel"}]) +@mock.patch(SQL_FACADE_SET_RELEASE_DIRECTIVE) +def test_given_named_directive_with_accounts_when_release_directive_set_then_success( + set_release_directive, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + application_package_entity.action_release_directive_set( + action_ctx=action_context, + version="1.0", + patch=2, + release_channel="test_channel", + release_directive="directive", + target_accounts=["org1.account1", "org2.account2"], + ) + + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + set_release_directive.assert_called_once_with( + package_name=pkg_model.fqn.name, + role=pkg_model.meta.role, + version="1.0", + patch=2, + release_channel="test_channel", + release_directive="directive", + target_accounts=["org1.account1", "org2.account2"], + ) + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[{"name": "test_channel"}]) +@mock.patch(SQL_FACADE_SET_RELEASE_DIRECTIVE) +def test_given_default_directive_with_no_accounts_when_release_directive_set_then_success( + set_release_directive, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + application_package_entity.action_release_directive_set( + action_ctx=action_context, + version="1.0", + patch=2, + release_channel="test_channel", + release_directive="default", + target_accounts=None, + ) + + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + set_release_directive.assert_called_once_with( + package_name=pkg_model.fqn.name, + role=pkg_model.meta.role, + version="1.0", + patch=2, + release_channel="test_channel", + release_directive="default", + target_accounts=None, + ) + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[]) +@mock.patch(SQL_FACADE_SET_RELEASE_DIRECTIVE) +def test_given_no_channels_with_default_channel_used_when_release_directive_set_then_success( + set_release_directive, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + application_package_entity.action_release_directive_set( + action_ctx=action_context, + version="1.0", + patch=2, + release_channel="default", + release_directive="default", + target_accounts=None, + ) + + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + set_release_directive.assert_called_once_with( + package_name=pkg_model.fqn.name, + role=pkg_model.meta.role, + version="1.0", + patch=2, + release_channel=None, + release_directive="default", + target_accounts=None, + ) + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[]) +@mock.patch(SQL_FACADE_SET_RELEASE_DIRECTIVE) +def test_given_no_channels_with_non_default_channel_used_when_release_directive_set_then_error( + set_release_directive, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + with pytest.raises(ClickException) as e: + application_package_entity.action_release_directive_set( + action_ctx=action_context, + version="1.0", + patch=2, + release_channel="non_default", + release_directive="default", + target_accounts=None, + ) + + assert ( + str(e.value) + == f"Release channel non_default does not exist in application package {pkg_model.fqn.name}." + ) + + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + set_release_directive.assert_not_called() + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[{"name": "test_channel"}]) +@mock.patch(SQL_FACADE_MODIFY_RELEASE_DIRECTIVE) +def test_given_named_directive_with_no_accounts_when_release_directive_set_then_modify_existing_directive( + modify_release_directive, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + application_package_entity.action_release_directive_set( + action_ctx=action_context, + version="1.0", + patch=2, + release_channel="test_channel", + release_directive="directive", + target_accounts=None, + ) + + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + modify_release_directive.assert_called_once_with( + package_name=pkg_model.fqn.name, + role=pkg_model.meta.role, + version="1.0", + patch=2, + release_channel="test_channel", + release_directive="directive", + ) + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[{"name": "test_channel"}]) +@mock.patch(SQL_FACADE_SET_RELEASE_DIRECTIVE) +def test_given_default_directive_with_accounts_when_release_directive_set_then_error( + set_release_directive, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + with pytest.raises(ClickException) as e: + application_package_entity.action_release_directive_set( + action_ctx=action_context, + version="1.0", + patch=2, + release_channel="test_channel", + release_directive="default", + target_accounts=["org1.account1", "org2.account2"], + ) + + assert ( + str(e.value) + == "Target accounts can only be specified for non-default named release directives." + ) + + set_release_directive.assert_not_called() + + +# test with target_account not in org.account format: +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[{"name": "test_channel"}]) +@mock.patch(SQL_FACADE_SET_RELEASE_DIRECTIVE) +@pytest.mark.parametrize( + "account_name", ["org1", "org1.", ".account1", "org1.acc.ount1"] +) +def test_given_invalid_account_names_when_release_directive_set_then_error( + set_release_directive, + show_release_channels, + application_package_entity, + action_context, + account_name, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + with pytest.raises(ClickException) as e: + application_package_entity.action_release_directive_set( + action_ctx=action_context, + version="1.0", + patch=2, + release_channel="test_channel", + release_directive="directive", + target_accounts=[account_name], + ) + + assert ( + str(e.value) + == f"Target account {account_name} is not in a valid format. Make sure you provide the target account in the format 'org.account'." + ) + + show_release_channels.assert_not_called() + set_release_directive.assert_not_called() + + +@mock.patch( + SQL_FACADE_SHOW_RELEASE_CHANNELS, + return_value=[{"name": "my_channel"}, {"name": "default"}], +) +@mock.patch(SQL_FACADE_UNSET_RELEASE_DIRECTIVE) +def test_given_channels_enabled_and_default_channel_selected_when_release_directive_unset_then_success( + unset_release_directive, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + application_package_entity.action_release_directive_unset( + action_ctx=action_context, + release_channel="default", + release_directive="directive", + ) + + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + unset_release_directive.assert_called_once_with( + package_name=pkg_model.fqn.name, + role=pkg_model.meta.role, + release_channel="default", + release_directive="directive", + ) + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[{"name": "my_channel"}]) +@mock.patch(SQL_FACADE_UNSET_RELEASE_DIRECTIVE) +def test_given_channels_enabled_and_non_default_channel_selected_when_release_directive_unset_then_success( + unset_release_directive, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + application_package_entity.action_release_directive_unset( + action_ctx=action_context, + release_channel="my_channel", + release_directive="directive", + ) + + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + unset_release_directive.assert_called_once_with( + package_name=pkg_model.fqn.name, + role=pkg_model.meta.role, + release_channel="my_channel", + release_directive="directive", + ) + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[]) +@mock.patch(SQL_FACADE_UNSET_RELEASE_DIRECTIVE) +def test_given_channels_disabled_and_default_channel_selected_when_release_directive_unset_then_success( + unset_release_directive, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + application_package_entity.action_release_directive_unset( + action_ctx=action_context, + release_channel="default", + release_directive="directive", + ) + + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + unset_release_directive.assert_called_once_with( + package_name=pkg_model.fqn.name, + role=pkg_model.meta.role, + release_channel=None, + release_directive="directive", + ) + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[]) +@mock.patch(SQL_FACADE_UNSET_RELEASE_DIRECTIVE) +def test_given_channels_disabled_and_non_default_channel_selected_when_release_directive_unset_then_error( + unset_release_directive, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + with pytest.raises(ClickException) as e: + application_package_entity.action_release_directive_unset( + action_ctx=action_context, + release_channel="non_default", + release_directive="directive", + ) + + assert ( + str(e.value) + == f"Release channel non_default does not exist in application package {pkg_model.fqn.name}." + ) + + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + unset_release_directive.assert_not_called() + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[{"name": "my_channel"}]) +@mock.patch(SQL_FACADE_UNSET_RELEASE_DIRECTIVE) +def test_given_channels_enabled_and_non_existing_channel_selected_when_release_directive_unset_then_error( + unset_release_directive, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + with pytest.raises(ClickException) as e: + application_package_entity.action_release_directive_unset( + action_ctx=action_context, + release_channel="non_existing", + release_directive="directive", + ) + + assert ( + str(e.value) + == f"Release channel non_existing does not exist in application package {pkg_model.fqn.name}." + ) + + show_release_channels.assert_called_once_with( + pkg_model.fqn.name, pkg_model.meta.role + ) + + unset_release_directive.assert_not_called() + + +@mock.patch(SQL_FACADE_SHOW_RELEASE_CHANNELS, return_value=[{"name": "default"}]) +@mock.patch(SQL_FACADE_UNSET_RELEASE_DIRECTIVE) +def test_given_default_directive_selected_when_directive_unset_then_error( + unset_release_directive, + show_release_channels, + application_package_entity, + action_context, +): + pkg_model = application_package_entity._entity_model # noqa SLF001 + pkg_model.meta.role = "package_role" + + with pytest.raises(ClickException) as e: + application_package_entity.action_release_directive_unset( + action_ctx=action_context, + release_channel="default", + release_directive="default", + ) + + assert ( + str(e.value) + == "Cannot unset default release directive. Please specify a non-default release directive." + ) + + show_release_channels.assert_not_called() + + unset_release_directive.assert_not_called() diff --git a/tests/nativeapp/test_sf_sql_facade.py b/tests/nativeapp/test_sf_sql_facade.py index 0e847108e5..c0cb93c33f 100644 --- a/tests/nativeapp/test_sf_sql_facade.py +++ b/tests/nativeapp/test_sf_sql_facade.py @@ -42,13 +42,20 @@ ) from snowflake.cli.api.constants import ObjectType from snowflake.cli.api.errno import ( + ACCOUNT_DOES_NOT_EXIST, + ACCOUNT_HAS_TOO_MANY_QUALIFIERS, APPLICATION_INSTANCE_FAILED_TO_RUN_SETUP_SCRIPT, APPLICATION_REQUIRES_TELEMETRY_SHARING, CANNOT_DISABLE_MANDATORY_TELEMETRY, + CANNOT_DISABLE_RELEASE_CHANNELS, DOES_NOT_EXIST_OR_CANNOT_BE_PERFORMED, INSUFFICIENT_PRIVILEGES, NO_WAREHOUSE_SELECTED_IN_SESSION, + RELEASE_DIRECTIVE_DOES_NOT_EXIST, + RELEASE_DIRECTIVES_VERSION_PATCH_NOT_FOUND, SQL_COMPILATION_ERROR, + VERSION_DOES_NOT_EXIST, + VERSION_NOT_ADDED_TO_RELEASE_CHANNEL, ) from snowflake.connector import DatabaseError, DictCursor, Error from snowflake.connector.errors import ( @@ -60,6 +67,7 @@ from tests.nativeapp.utils import ( SQL_EXECUTOR_EXECUTE, SQL_EXECUTOR_EXECUTE_QUERIES, + SQL_FACADE_GET_UI_PARAMETER, assert_programmingerror_cause_with_errno, mock_execute_helper, ) @@ -2559,6 +2567,37 @@ def test_given_privilege_exception_when_update_application_package_then_raise_pr ) +def test_given_error_disabling_release_channel_when_update_application_package_then_raise_user_input_error( + mock_execute_query, + mock_use_role, +): + pkg_name = "test_pkg" + role = "test_role" + side_effects, expected = mock_execute_helper( + [ + ( + ProgrammingError(errno=CANNOT_DISABLE_RELEASE_CHANNELS), + mock.call( + dedent( + f"""\ + alter application package {pkg_name} + set enable_release_channels = False + """ + ) + ), + ) + ] + ) + mock_execute_query.side_effect = side_effects + + with pytest.raises(UserInputError) as err: + sql_facade.alter_application_package_properties( + pkg_name, enable_release_channels=False, role=role + ) + + assert "Cannot disable release channels for application package" in str(err) + + expected_ui_params_query = "call system$bootstrap_data_request('CLIENT_PARAMS_INFO')" @@ -2647,3 +2686,588 @@ def test_get_ui_parameter_with_no_value_then_use_default(mock_cursor): ) execute_str_mock.assert_called_once_with(expected_ui_params_query) + + +def test_show_release_directives_no_release_channel_specified(mock_execute_query): + package_name = "test_package" + expected_query = f"show release directives in application package {package_name}" + + sql_facade.show_release_directives(package_name) + + mock_execute_query.assert_called_once_with(expected_query, cursor_class=DictCursor) + + +def test_show_release_directive_with_release_channel_specified(mock_execute_query): + package_name = "test_package" + release_channel = "test_channel" + expected_query = f"show release directives in application package {package_name} for release channel {release_channel}" + sql_facade.show_release_directives(package_name, release_channel) + + mock_execute_query.assert_called_once_with(expected_query, cursor_class=DictCursor) + + +def test_show_release_directive_with_special_characters_in_names(mock_execute_query): + package_name = "test.package" + release_channel = "test.channel" + expected_query = f'show release directives in application package "{package_name}" for release channel "{release_channel}"' + sql_facade.show_release_directives(package_name, release_channel) + + mock_execute_query.assert_called_once_with(expected_query, cursor_class=DictCursor) + + +def test_show_release_directive_with_error(mock_execute_query): + package_name = "test_package" + release_channel = "test_channel" + expected_query = f"show release directives in application package {package_name} for release channel {release_channel}" + mock_execute_query.side_effect = ProgrammingError() + + with pytest.raises(InvalidSQLError): + sql_facade.show_release_directives(package_name, release_channel) + + mock_execute_query.assert_called_once_with(expected_query, cursor_class=DictCursor) + + +def test_show_release_directive_with_permission_error(mock_execute_query): + package_name = "test_package" + release_channel = "test_channel" + expected_query = f"show release directives in application package {package_name} for release channel {release_channel}" + mock_execute_query.side_effect = ProgrammingError(errno=INSUFFICIENT_PRIVILEGES) + + with pytest.raises(InsufficientPrivilegesError): + sql_facade.show_release_directives(package_name, release_channel) + + mock_execute_query.assert_called_once_with(expected_query, cursor_class=DictCursor) + + +@mock.patch(SQL_FACADE_GET_UI_PARAMETER, return_value=False) +def test_show_release_channels_when_feature_not_enabled( + mock_get_ui_parameter, mock_execute_query, mock_cursor +): + package_name = "test_package" + + result = sql_facade.show_release_channels(package_name) + + assert result == [] + mock_get_ui_parameter.assert_called_once_with( + UIParameter.NA_FEATURE_RELEASE_CHANNELS, True + ) + mock_execute_query.assert_not_called() + + +@mock.patch(SQL_FACADE_GET_UI_PARAMETER, return_value=True) +@pytest.mark.parametrize( + "package_name, expected_used_package_name", + [("test_package", "test_package"), ("test.package", '"test.package"')], +) +def test_show_release_channels_when_feature_enabled( + mock_get_ui_parameter, + mock_execute_query, + mock_cursor, + package_name, + expected_used_package_name, +): + + expected_query = ( + f"show release channels in application package {expected_used_package_name}" + ) + mock_cursor_results = [ + { + "NAME": "test_channel", + "VERSIONS": '["V1"]', + "TARGETS": '{"accounts": []}', + } + ] + mock_execute_query.side_effect = [mock_cursor(mock_cursor_results, [])] + + result = sql_facade.show_release_channels(package_name) + + assert result == mock_cursor_results + mock_get_ui_parameter.assert_called_once_with( + UIParameter.NA_FEATURE_RELEASE_CHANNELS, True + ) + mock_execute_query.assert_called_once_with(expected_query, cursor_class=DictCursor) + + +@mock.patch(SQL_FACADE_GET_UI_PARAMETER, return_value=True) +def test_show_release_channels_when_error( + mock_get_ui_parameter, mock_execute_query, mock_cursor +): + package_name = "test_package" + + expected_query = f"show release channels in application package {package_name}" + mock_execute_query.side_effect = ProgrammingError() + + with pytest.raises(InvalidSQLError): + sql_facade.show_release_channels(package_name) + + mock_get_ui_parameter.assert_called_once_with( + UIParameter.NA_FEATURE_RELEASE_CHANNELS, True + ) + mock_execute_query.assert_called_once_with(expected_query, cursor_class=DictCursor) + + +def test_unset_release_directive_with_release_channel( + mock_execute_query, +): + package_name = "test_package" + release_directive = "test_directive" + release_channel = "test_channel" + expected_query = f"alter application package {package_name} modify release channel {release_channel} unset release directive {release_directive}" + + sql_facade.unset_release_directive(package_name, release_directive, release_channel) + + mock_execute_query.assert_called_once_with(expected_query) + + +def test_unset_release_directive_for_default_channel(mock_execute_query): + package_name = "test_package" + release_directive = "test_directive" + release_channel = "DEFAULT" + expected_query = f"alter application package {package_name} modify release channel {release_channel} unset release directive {release_directive}" + + sql_facade.unset_release_directive(package_name, release_directive, release_channel) + + mock_execute_query.assert_called_once_with(expected_query) + + +def test_unset_release_directive_with_special_chars_in_names(mock_execute_query): + package_name = "test.package" + release_directive = "test.directive" + release_channel = "test.channel" + expected_query = f'alter application package "{package_name}" modify release channel "{release_channel}" unset release directive "{release_directive}"' + + sql_facade.unset_release_directive(package_name, release_directive, release_channel) + + mock_execute_query.assert_called_once_with(expected_query) + + +def test_unset_release_directive_without_release_channel( + mock_execute_query, +): + package_name = "test_package" + release_directive = "test_directive" + expected_query = f"alter application package {package_name} unset release directive {release_directive}" + + sql_facade.unset_release_directive(package_name, release_directive, None) + + mock_execute_query.assert_called_once_with(expected_query) + + +def test_unset_release_directive_where_directive_does_not_exist( + mock_execute_query, +): + package_name = "test_package" + release_directive = "test_directive" + release_channel = "test_channel" + mock_execute_query.side_effect = ProgrammingError( + errno=RELEASE_DIRECTIVE_DOES_NOT_EXIST + ) + + with pytest.raises(UserInputError): + sql_facade.unset_release_directive( + package_name, release_directive, release_channel + ) + + mock_execute_query.assert_called_once() + + +def test_unset_release_directive_with_error(mock_execute_query): + package_name = "test_package" + release_directive = "test_directive" + release_channel = "test_channel" + mock_execute_query.side_effect = ProgrammingError() + + with pytest.raises(InvalidSQLError): + sql_facade.unset_release_directive( + package_name, release_directive, release_channel + ) + + mock_execute_query.assert_called_once() + + +def test_set_release_directive_with_non_default_directive( + mock_execute_query, +): + package_name = "test_package" + release_directive = "test_directive" + release_channel = "test_channel" + version = "1.0.0" + patch = 1 + target_accounts = ["account1"] + expected_query = dedent( + f"""\ + alter application package {package_name} + modify release channel {release_channel} + set release directive {release_directive} + accounts = ({",".join(target_accounts)}) + version = "{version}" patch = {patch} + """ + ).strip() + + sql_facade.set_release_directive( + package_name, + release_directive, + release_channel, + target_accounts, + version, + patch, + ) + + mock_execute_query.assert_called_once_with(expected_query) + + +def test_set_default_release_directive( + mock_execute_query, +): + package_name = "test_package" + release_directive = "DEFAULT" + release_channel = "test_channel" + version = "1.0.0" + patch = 1 + target_accounts = None + expected_query = dedent( + f"""\ + alter application package {package_name} + modify release channel {release_channel} + set default release directive + version = "{version}" patch = {patch} + """ + ).strip() + + sql_facade.set_release_directive( + package_name, + release_directive, + release_channel, + target_accounts, + version, + patch, + ) + + mock_execute_query.assert_called_once_with(expected_query) + + +def test_set_release_directive_with_special_chars_in_names( + mock_execute_query, +): + package_name = "test.package" + release_directive = "test.directive" + release_channel = "test.channel" + version = "1.0.0" + patch = 1 + target_accounts = ["account1"] + expected_query = dedent( + f"""\ + alter application package "{package_name}" + modify release channel "{release_channel}" + set release directive "{release_directive}" + accounts = ({",".join(target_accounts)}) + version = "{version}" patch = {patch} + """ + ).strip() + + sql_facade.set_release_directive( + package_name, + release_directive, + release_channel, + target_accounts, + version, + patch, + ) + + mock_execute_query.assert_called_once_with(expected_query) + + +def test_set_release_directive_no_release_channel( + mock_execute_query, +): + package_name = "test_package" + release_directive = "test_directive" + version = "1.0.0" + patch = 1 + target_accounts = ["account1"] + expected_query = dedent( + f"""\ + alter application package {package_name} + set release directive {release_directive} + accounts = ({",".join(target_accounts)}) + version = "{version}" patch = {patch} + """ + ).strip() + + sql_facade.set_release_directive( + package_name, + release_directive, + None, + target_accounts, + version, + patch, + ) + + mock_execute_query.assert_called_once_with(expected_query) + + +def test_set_default_release_directive_no_release_channel( + mock_execute_query, +): + package_name = "test_package" + release_directive = "DEFAULT" + version = "1.0.0" + patch = 1 + target_accounts = None + expected_query = dedent( + f"""\ + alter application package {package_name} + set default release directive + version = "{version}" patch = {patch} + """ + ).strip() + + sql_facade.set_release_directive( + package_name, + release_directive, + None, + target_accounts, + version, + patch, + ) + + mock_execute_query.assert_called_once_with(expected_query) + + +@pytest.mark.parametrize( + "error_raised, error_caught, error_message", + [ + ( + ProgrammingError(errno=VERSION_NOT_ADDED_TO_RELEASE_CHANNEL), + UserInputError, + 'Version "1.0.0" is not added to release channel test_channel. Please add it to the release channel first.', + ), + ( + ProgrammingError(errno=RELEASE_DIRECTIVES_VERSION_PATCH_NOT_FOUND), + UserInputError, + 'Patch 1 for version "1.0.0" not found in application package test_package.', + ), + ( + ProgrammingError(errno=VERSION_DOES_NOT_EXIST), + UserInputError, + 'Version "1.0.0" does not exist in application package test_package.', + ), + ( + ProgrammingError(errno=ACCOUNT_DOES_NOT_EXIST), + UserInputError, + "Invalid account passed in.", + ), + ( + ProgrammingError(errno=ACCOUNT_HAS_TOO_MANY_QUALIFIERS), + UserInputError, + "Invalid account passed in.", + ), + ( + ProgrammingError(), + InvalidSQLError, + "Failed to set release directive test_directive for package test_package.", + ), + ], +) +@mock.patch(SQL_EXECUTOR_EXECUTE) +def test_set_release_directive_errors( + mock_execute_query, error_raised, error_caught, error_message +): + mock_execute_query.side_effect = error_raised + + with pytest.raises(error_caught) as err: + sql_facade.set_release_directive( + "test_package", + "test_directive", + "test_channel", + ["account1"], + "1.0.0", + 1, + None, + ) + + assert error_message in str(err) + + +def test_modify_release_directive_with_non_default_directive( + mock_execute_query, +): + package_name = "test_package" + release_directive = "test_directive" + release_channel = "test_channel" + version = "1.0.0" + patch = 1 + expected_query = dedent( + f"""\ + alter application package {package_name} + modify release channel {release_channel} + modify release directive {release_directive} + version = "{version}" patch = {patch} + """ + ).strip() + + sql_facade.modify_release_directive( + package_name, + release_directive, + release_channel, + version, + patch, + ) + + mock_execute_query.assert_called_once_with(expected_query) + + +def test_modify_release_directive_with_default_directive( + mock_execute_query, +): + package_name = "test_package" + release_directive = "DEFAULT" + release_channel = "test_channel" + version = "1.0.0" + patch = 1 + expected_query = dedent( + f"""\ + alter application package {package_name} + modify release channel {release_channel} + modify default release directive + version = "{version}" patch = {patch} + """ + ).strip() + + sql_facade.modify_release_directive( + package_name, + release_directive, + release_channel, + version, + patch, + ) + + mock_execute_query.assert_called_once_with(expected_query) + + +def test_modify_release_directive_with_special_chars_in_names( + mock_execute_query, +): + package_name = "test.package" + release_directive = "test.directive" + release_channel = "test.channel" + version = "1.0.0" + patch = 1 + expected_query = dedent( + f"""\ + alter application package "{package_name}" + modify release channel "{release_channel}" + modify release directive "{release_directive}" + version = "{version}" patch = {patch} + """ + ).strip() + + sql_facade.modify_release_directive( + package_name, + release_directive, + release_channel, + version, + patch, + ) + + mock_execute_query.assert_called_once_with(expected_query) + + +def test_modify_release_directive_no_release_channel( + mock_execute_query, +): + package_name = "test_package" + release_directive = "test_directive" + version = "1.0.0" + patch = 1 + expected_query = dedent( + f"""\ + alter application package {package_name} + modify release directive {release_directive} + version = "{version}" patch = {patch} + """ + ).strip() + + sql_facade.modify_release_directive( + package_name, + release_directive, + None, + version, + patch, + ) + + mock_execute_query.assert_called_once_with(expected_query) + + +def test_modify_default_release_directive_no_release_channel( + mock_execute_query, +): + package_name = "test_package" + release_directive = "DEFAULT" + version = "1.0.0" + patch = 1 + expected_query = dedent( + f"""\ + alter application package {package_name} + modify default release directive + version = "{version}" patch = {patch} + """ + ).strip() + + sql_facade.modify_release_directive( + package_name, + release_directive, + None, + version, + patch, + ) + + mock_execute_query.assert_called_once_with(expected_query) + + +@pytest.mark.parametrize( + "error_raised, error_caught, error_message", + [ + ( + ProgrammingError(errno=VERSION_NOT_ADDED_TO_RELEASE_CHANNEL), + UserInputError, + 'Version "1.0.0" is not added to release channel test_channel. Please add it to the release channel first.', + ), + ( + ProgrammingError(errno=RELEASE_DIRECTIVES_VERSION_PATCH_NOT_FOUND), + UserInputError, + 'Patch 1 for version "1.0.0" not found in application package test_package.', + ), + ( + ProgrammingError(errno=VERSION_DOES_NOT_EXIST), + UserInputError, + 'Version "1.0.0" does not exist in application package test_package.', + ), + ( + ProgrammingError(errno=RELEASE_DIRECTIVE_DOES_NOT_EXIST), + UserInputError, + "Release directive test_directive does not exist in application package test_package.", + ), + ( + ProgrammingError(), + InvalidSQLError, + "Failed to modify release directive test_directive for package test_package.", + ), + ], +) +@mock.patch(SQL_EXECUTOR_EXECUTE) +def test_modify_release_directive_errors( + mock_execute_query, error_raised, error_caught, error_message +): + mock_execute_query.side_effect = error_raised + + with pytest.raises(error_caught) as err: + sql_facade.modify_release_directive( + "test_package", + "test_directive", + "test_channel", + "1.0.0", + 1, + ) + + assert error_message in str(err) diff --git a/tests/nativeapp/utils.py b/tests/nativeapp/utils.py index 2eb4c852d1..7b9db67291 100644 --- a/tests/nativeapp/utils.py +++ b/tests/nativeapp/utils.py @@ -89,6 +89,11 @@ f"{SQL_FACADE}.alter_application_package_properties" ) SQL_FACADE_CREATE_APP_PKG = f"{SQL_FACADE}.create_application_package" +SQL_FACADE_SHOW_RELEASE_DIRECTIVES = f"{SQL_FACADE}.show_release_directives" +SQL_FACADE_SET_RELEASE_DIRECTIVE = f"{SQL_FACADE}.set_release_directive" +SQL_FACADE_MODIFY_RELEASE_DIRECTIVE = f"{SQL_FACADE}.modify_release_directive" +SQL_FACADE_UNSET_RELEASE_DIRECTIVE = f"{SQL_FACADE}.unset_release_directive" +SQL_FACADE_SHOW_RELEASE_CHANNELS = f"{SQL_FACADE}.show_release_channels" mock_snowflake_yml_file = dedent( """\ diff --git a/tests/project/test_util.py b/tests/project/test_util.py index dc33902968..ff0827053e 100644 --- a/tests/project/test_util.py +++ b/tests/project/test_util.py @@ -19,13 +19,16 @@ append_to_identifier, concat_identifiers, escape_like_pattern, + identifier_in_list, identifier_to_str, is_valid_identifier, is_valid_object_name, is_valid_quoted_identifier, is_valid_string_literal, is_valid_unquoted_identifier, + same_identifiers, sanitize_identifier, + sql_match, to_identifier, to_quoted_identifier, to_string_literal, @@ -338,6 +341,82 @@ def test_sanitize_identifier(identifier, expected_value): assert sanitize_identifier(identifier) == expected_value +def test_same_identifiers(): + # both unquoted, same case: + assert same_identifiers("abc", "abc") + + # both unquoted, different case: + assert same_identifiers("abc", "ABC") + + # both quoted, same case: + assert same_identifiers('"abc"', '"abc"') + + # both quoted, different case: + assert not same_identifiers('"abc"', '"ABC"') + + # one quoted, one unquoted - unquoted is lowercase: + assert not same_identifiers("abc", '"abc"') + + # one quoted, one unquoted - unquoted is uppercase: + assert same_identifiers("abc", '"ABC"') + + # one quoted, one unquoted - unquoted has special characters, so treat it as quoted: + assert same_identifiers("a.bc", '"a.bc"') + + # blank strings: + assert same_identifiers("", '""') + + +def test_sql_match(): + # simple case with a match: + assert sql_match(pattern="abc", value="abc") + + # simple case that does not match: + assert not sql_match(pattern="abc", value="def") + + # case with a match but uses '_' as a wildcard: + assert sql_match(pattern="a_c", value="abc") + + # case with wildcard '_' but does not match due to different length: + assert not sql_match(pattern="a_c", value="abcd") + + # case that match with wildcard '%': + assert sql_match(pattern="a%b", value="a123b") + + # case that match with wildcard '%' but 0 characters: + assert sql_match(pattern="a%b", value="ab") + + # case that does not match because '%' is in the wrong place: + assert not sql_match(pattern="a%b", value="ab123") + + # case with '%%' that matches everything: + assert sql_match(pattern="%%", value="abc") + + # case with quoted identifier: + assert sql_match(pattern="a_c", value='"a_c"') + + +def test_identifier_in_list(): + # one ID matching: + assert identifier_in_list("abc", ["def", "abc", "ghi"]) + + # id not matching: + assert not identifier_in_list("abc", ["def", "ghi"]) + + # id matching with case insensitivity: + assert identifier_in_list("AbC", ["dEf", "aBc", "gHi"]) + + # id with quotes matching: + assert identifier_in_list('"ABC"', ["def", "abc", "ghi"]) + + # id with quotes not matching due to case: + assert not identifier_in_list('"abc"', ["DEF", "ABC", "GHI"]) + assert not identifier_in_list('"abc"', ["def", "abc", "ghi"]) + + # test with empty list: + assert not identifier_in_list("abc", []) + + @pytest.mark.parametrize( "identifier, expected", [ From 8c3c85d5509096539f5f4f377a4e810da98e898b Mon Sep 17 00:00:00 2001 From: Michel El Nacouzi Date: Wed, 11 Dec 2024 14:32:28 -0500 Subject: [PATCH 3/5] Add support for release channels in snow app version create (#1946) --- RELEASE-NOTES.md | 3 + .../nativeapp/entities/application_package.py | 31 +-- .../cli/_plugins/nativeapp/sf_sql_facade.py | 71 ++++- .../_plugins/nativeapp/version/commands.py | 26 +- tests/nativeapp/test_sf_sql_facade.py | 251 +++++++++++++++++- tests/nativeapp/test_version_create.py | 110 ++++---- tests/nativeapp/test_version_drop.py | 57 +--- tests/nativeapp/utils.py | 2 + .../nativeapp/__snapshots__/test_version.ambr | 4 +- tests_integration/nativeapp/test_version.py | 70 +++++ 10 files changed, 484 insertions(+), 141 deletions(-) diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 739758cab1..d2b0da52b7 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -23,10 +23,13 @@ * `snow app release-directive list` * `snow app release-directive set` * `snow app release-directive unset` +* Add support for release channels feature in native app version creation/drop. +* `snow app version create` now returns version, patch, and label in JSON format. ## Fixes and improvements * Fixed crashes with older x86_64 Intel CPUs. * Fixed inability to add patches to lowercase quoted versions +* Fixes label being set to blank instead of None when not provided. # v3.2.0 diff --git a/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py b/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py index 35d5963f73..54c643a628 100644 --- a/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py +++ b/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py @@ -370,7 +370,7 @@ def action_version_create( force: bool, *args, **kwargs, - ): + ) -> VersionInfo: """ Create a version and/or patch for a new or existing application package. Always performs a deploy action before creating version or patch. @@ -453,12 +453,14 @@ def action_version_create( # Define a new version in the application package if not self.get_existing_version_info(resolved_version): self.add_new_version(version=resolved_version, label=resolved_label) - return # A new version created automatically has patch 0, we do not need to further increment the patch. + # A new version created automatically has patch 0, we do not need to further increment the patch. + return VersionInfo(resolved_version, 0, resolved_label) # Add a new patch to an existing (old) version - self.add_new_patch_to_version( + patch = self.add_new_patch_to_version( version=resolved_version, patch=resolved_patch, label=resolved_label ) + return VersionInfo(resolved_version, patch, resolved_label) def action_version_drop( self, @@ -537,14 +539,9 @@ def action_version_drop( raise typer.Exit(1) # Drop the version - sql_executor = get_sql_executor() - with sql_executor.use_role(self.role): - try: - sql_executor.execute_query( - f"alter application package {self.name} drop version {version}" - ) - except ProgrammingError as err: - raise err # e.g. version is referenced in a release directive(s) + get_snowflake_facade().drop_version_from_package( + package_name=self.name, version=version, role=self.role + ) console.message( f"Version {version} in application package {self.name} dropped successfully." @@ -846,9 +843,10 @@ def add_new_version(self, version: str, label: str | None = None) -> None: def add_new_patch_to_version( self, version: str, patch: int | None = None, label: str | None = None - ): + ) -> int: """ Add a new patch, optionally a custom one, to an existing version in an application package. + Returns the patch number of the newly created patch. """ console = self._workspace_ctx.console @@ -868,6 +866,7 @@ def add_new_patch_to_version( console.message( f"Patch {new_patch}{with_label_prompt} created for version {version} defined in application package {self.name}." ) + return new_patch def check_index_changes_in_git_repo( self, policy: PolicyBase, interactive: bool @@ -1134,7 +1133,7 @@ def resolve_version_info( bundle_map: BundleMap | None, policy: PolicyBase, interactive: bool, - ): + ) -> VersionInfo: """Determine version name, patch number, and label from CLI provided values and manifest.yml version entry. @param [Optional] version: version name as specified in the command @param [Optional] patch: patch number as specified in the command @@ -1142,12 +1141,14 @@ def resolve_version_info( @param [Optional] bundle_map: bundle_map if a deploy_root is prepared. _bundle() is performed otherwise. @param policy: CLI policy @param interactive: True if command is run in interactive mode, otherwise False + + @return VersionInfo: version_name, patch_number, label resolved from CLI and manifest.yml """ console = self._workspace_ctx.console resolved_version = None resolved_patch = None - resolved_label = "" + resolved_label = None # If version is specified in CLI, no version information from manifest.yml is used (except for comment, we can't control comment as of now). if version is not None: @@ -1155,7 +1156,7 @@ def resolve_version_info( "Ignoring version information from the application manifest since a version was explicitly specified with the command." ) resolved_patch = patch - resolved_label = label if label is not None else "" + resolved_label = label resolved_version = version # When version is not set by CLI, version name is read from manifest.yml. patch and label from CLI will be used, if provided. diff --git a/src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py b/src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py index 9bbed2b4cc..f3c164f4a8 100644 --- a/src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py +++ b/src/snowflake/cli/_plugins/nativeapp/sf_sql_facade.py @@ -53,6 +53,7 @@ NO_WAREHOUSE_SELECTED_IN_SESSION, RELEASE_DIRECTIVE_DOES_NOT_EXIST, RELEASE_DIRECTIVES_VERSION_PATCH_NOT_FOUND, + SQL_COMPILATION_ERROR, VERSION_DOES_NOT_EXIST, VERSION_NOT_ADDED_TO_RELEASE_CHANNEL, ) @@ -264,27 +265,62 @@ def create_version_in_package( @param [Optional] label: Label for this version, visible to consumers. """ - # Make the version a valid identifier, adding quotes if necessary version = to_identifier(version) + package_name = to_identifier(package_name) + + available_release_channels = self.show_release_channels(package_name, role) # Label must be a string literal - with_label_cause = ( - f"\nlabel={to_string_literal(label)}" if label is not None else "" + with_label_clause = ( + f"label={to_string_literal(label)}" if label is not None else "" ) - add_version_query = dedent( - f"""\ - alter application package {package_name} - add version {version} - using @{stage_fqn}{with_label_cause} - """ + + action = "register" if available_release_channels else "add" + + query = dedent( + _strip_empty_lines( + f"""\ + alter application package {package_name} + {action} version {version} + using @{stage_fqn} + {with_label_clause} + """ + ) ) + + with self._use_role_optional(role): + try: + self._sql_executor.execute_query(query) + except Exception as err: + handle_unclassified_error( + err, + f"Failed to {action} version {version} to application package {package_name}.", + ) + + def drop_version_from_package( + self, package_name: str, version: str, role: str | None = None + ): + """ + Drops a version from an existing application package. + @param package_name: Name of the application package to alter. + @param version: Version name to drop. + @param [Optional] role: Switch to this role while executing drop version. + """ + + version = to_identifier(version) + package_name = to_identifier(package_name) + + release_channels = self.show_release_channels(package_name, role) + action = "deregister" if release_channels else "drop" + + query = f"alter application package {package_name} {action} version {version}" with self._use_role_optional(role): try: - self._sql_executor.execute_query(add_version_query) + self._sql_executor.execute_query(query) except Exception as err: handle_unclassified_error( err, - f"Failed to add version {version} to application package {package_name}.", + f"Failed to {action} version {version} from application package {package_name}.", ) def add_patch_to_package_version( @@ -1085,6 +1121,10 @@ def show_release_channels( cursor_class=DictCursor, ) except ProgrammingError as err: + # TODO: Temporary check for syntax until UI Parameter is available in production + if err.errno == SQL_COMPILATION_ERROR: + # Release not out yet and param not out yet + return [] handle_unclassified_error( err, f"Failed to show release channels for application package {package_name}.", @@ -1095,8 +1135,15 @@ def show_release_channels( def _strip_empty_lines(text: str) -> str: """ Strips empty lines from the input string. + Preserves the new line at the end of the string if it exists. """ - return "\n".join(line for line in text.splitlines() if line.strip()) + all_lines = text.splitlines() + + # join all non-empty lines, but preserve the new line at the end if it exists + last_line = all_lines[-1] + other_lines = [line for line in all_lines[:-1] if line.strip()] + + return "\n".join(other_lines) + "\n" + last_line def _handle_release_directive_version_error( diff --git a/src/snowflake/cli/_plugins/nativeapp/version/commands.py b/src/snowflake/cli/_plugins/nativeapp/version/commands.py index ad1cd5f1c0..b7ad13c0c0 100644 --- a/src/snowflake/cli/_plugins/nativeapp/version/commands.py +++ b/src/snowflake/cli/_plugins/nativeapp/version/commands.py @@ -18,6 +18,7 @@ from typing import Optional import typer +from snowflake.cli._plugins.nativeapp.artifacts import VersionInfo from snowflake.cli._plugins.nativeapp.common_flags import ForceOption, InteractiveOption from snowflake.cli._plugins.nativeapp.v2_conversions.compat import ( force_project_definition_v2, @@ -29,7 +30,14 @@ ) from snowflake.cli.api.commands.snow_typer import SnowTyperFactory from snowflake.cli.api.entities.common import EntityActions -from snowflake.cli.api.output.types import CommandResult, MessageResult, QueryResult +from snowflake.cli.api.output.formats import OutputFormat +from snowflake.cli.api.output.types import ( + CommandResult, + MessageResult, + ObjectResult, + QueryResult, +) +from snowflake.cli.api.project.util import to_identifier app = SnowTyperFactory( name="version", @@ -78,7 +86,7 @@ def create( project_root=cli_context.project_root, ) package_id = options["package_entity_id"] - ws.perform_action( + result: VersionInfo = ws.perform_action( package_id, EntityActions.VERSION_CREATE, version=version, @@ -88,7 +96,19 @@ def create( interactive=interactive, skip_git_check=skip_git_check, ) - return MessageResult(f"Version create is now complete.") + + message = "Version create is now complete." + if cli_context.output_format == OutputFormat.JSON: + return ObjectResult( + { + "message": message, + "version": to_identifier(result.version_name), + "patch": result.patch_number, + "label": result.label, + } + ) + else: + return MessageResult(message) @app.command("list", requires_connection=True) diff --git a/tests/nativeapp/test_sf_sql_facade.py b/tests/nativeapp/test_sf_sql_facade.py index c0cb93c33f..8d8107249c 100644 --- a/tests/nativeapp/test_sf_sql_facade.py +++ b/tests/nativeapp/test_sf_sql_facade.py @@ -2355,7 +2355,7 @@ def test_given_basic_pkg_when_create_application_package_then_success( comment = {SPECIAL_COMMENT} distribution = {distribution} """ - ).strip() + ) ), ) ] @@ -2384,7 +2384,7 @@ def test_given_release_channels_when_create_application_package_then_success( distribution = {distribution} enable_release_channels = {str(enable_release_channels).lower()} """ - ).strip() + ) ), ) ] @@ -2416,7 +2416,7 @@ def test_given_programming_error_when_create_application_package_then_error( comment = {SPECIAL_COMMENT} distribution = {distribution} """ - ).strip() + ) ), ) ] @@ -2448,7 +2448,7 @@ def test_given_privilege_error_when_create_application_package_then_raise_priv_e comment = {SPECIAL_COMMENT} distribution = {distribution} """ - ).strip() + ) ), ) ] @@ -2902,7 +2902,7 @@ def test_set_release_directive_with_non_default_directive( accounts = ({",".join(target_accounts)}) version = "{version}" patch = {patch} """ - ).strip() + ) sql_facade.set_release_directive( package_name, @@ -2932,7 +2932,7 @@ def test_set_default_release_directive( set default release directive version = "{version}" patch = {patch} """ - ).strip() + ) sql_facade.set_release_directive( package_name, @@ -2963,7 +2963,7 @@ def test_set_release_directive_with_special_chars_in_names( accounts = ({",".join(target_accounts)}) version = "{version}" patch = {patch} """ - ).strip() + ) sql_facade.set_release_directive( package_name, @@ -2992,7 +2992,7 @@ def test_set_release_directive_no_release_channel( accounts = ({",".join(target_accounts)}) version = "{version}" patch = {patch} """ - ).strip() + ) sql_facade.set_release_directive( package_name, @@ -3020,7 +3020,7 @@ def test_set_default_release_directive_no_release_channel( set default release directive version = "{version}" patch = {patch} """ - ).strip() + ) sql_facade.set_release_directive( package_name, @@ -3104,7 +3104,7 @@ def test_modify_release_directive_with_non_default_directive( modify release directive {release_directive} version = "{version}" patch = {patch} """ - ).strip() + ) sql_facade.modify_release_directive( package_name, @@ -3132,7 +3132,7 @@ def test_modify_release_directive_with_default_directive( modify default release directive version = "{version}" patch = {patch} """ - ).strip() + ) sql_facade.modify_release_directive( package_name, @@ -3160,7 +3160,7 @@ def test_modify_release_directive_with_special_chars_in_names( modify release directive "{release_directive}" version = "{version}" patch = {patch} """ - ).strip() + ) sql_facade.modify_release_directive( package_name, @@ -3186,7 +3186,7 @@ def test_modify_release_directive_no_release_channel( modify release directive {release_directive} version = "{version}" patch = {patch} """ - ).strip() + ) sql_facade.modify_release_directive( package_name, @@ -3212,7 +3212,7 @@ def test_modify_default_release_directive_no_release_channel( modify default release directive version = "{version}" patch = {patch} """ - ).strip() + ) sql_facade.modify_release_directive( package_name, @@ -3271,3 +3271,226 @@ def test_modify_release_directive_errors( ) assert error_message in str(err) + + +@contextmanager +def mock_release_channels(facade, enabled): + with mock.patch.object( + facade, "show_release_channels" + ) as mock_show_release_channels: + mock_show_release_channels.return_value = ( + [{"name": "test_channel"}] if enabled else [] + ) + yield + + +@pytest.mark.parametrize("release_channels_enabled", [True, False]) +def test_create_version_in_package( + release_channels_enabled, mock_use_role, mock_execute_query +): + action = "register" if release_channels_enabled else "add" + package_name = "test_package" + version = "v1" + role = "test_role" + stage_fqn = f"{package_name}.app_src.stage" + + expected_use_objects = [ + (mock_use_role, mock.call(role)), + ] + expected_execute_query = [ + ( + mock_execute_query, + mock.call( + dedent( + f"""\ + alter application package {package_name} + {action} version {version} + using @{stage_fqn} + """ + ) + ), + ), + ] + + with mock_release_channels(sql_facade, release_channels_enabled): + with assert_in_context(expected_use_objects, expected_execute_query): + sql_facade.create_version_in_package( + package_name=package_name, + version=version, + role=role, + stage_fqn=stage_fqn, + ) + + +@pytest.mark.parametrize("release_channels_enabled", [True, False]) +@pytest.mark.parametrize("label", ["test_label", ""]) +def test_create_version_in_package_with_label( + label, release_channels_enabled, mock_use_role, mock_execute_query +): + action = "register" if release_channels_enabled else "add" + package_name = "test_package" + version = "v1" + role = "test_role" + stage_fqn = f"{package_name}.app_src.stage" + + expected_use_objects = [ + (mock_use_role, mock.call(role)), + ] + expected_execute_query = [ + ( + mock_execute_query, + mock.call( + dedent( + f"""\ + alter application package {package_name} + {action} version {version} + using @{stage_fqn} + label='{label}' + """ + ) + ), + ), + ] + + with mock_release_channels(sql_facade, release_channels_enabled): + with assert_in_context(expected_use_objects, expected_execute_query): + sql_facade.create_version_in_package( + package_name=package_name, + version=version, + role=role, + stage_fqn=stage_fqn, + label=label, + ) + + +@pytest.mark.parametrize("release_channels_enabled", [True, False]) +def test_create_version_with_special_characters( + release_channels_enabled, mock_use_role, mock_execute_query +): + action = "register" if release_channels_enabled else "add" + package_name = "test.package" + version = "v1.0" + role = "test_role" + stage_fqn = f"{package_name}.app_src.stage" + + expected_use_objects = [ + (mock_use_role, mock.call(role)), + ] + expected_execute_query = [ + ( + mock_execute_query, + mock.call( + dedent( + f"""\ + alter application package "{package_name}" + {action} version "{version}" + using @{stage_fqn} + """ + ) + ), + ), + ] + + with mock_release_channels(sql_facade, release_channels_enabled): + with assert_in_context(expected_use_objects, expected_execute_query): + sql_facade.create_version_in_package( + package_name=package_name, + version=version, + role=role, + stage_fqn=stage_fqn, + ) + + +@pytest.mark.parametrize("release_channels_enabled", [True, False]) +def test_create_version_in_package_with_error( + release_channels_enabled, mock_use_role, mock_execute_query +): + package_name = "test_package" + version = "v1" + role = "test_role" + stage_fqn = f"{package_name}.app_src.stage" + + mock_execute_query.side_effect = ProgrammingError() + + with mock_release_channels(sql_facade, release_channels_enabled): + with pytest.raises(InvalidSQLError): + sql_facade.create_version_in_package( + package_name=package_name, + version=version, + role=role, + stage_fqn=stage_fqn, + ) + + +@pytest.mark.parametrize("release_channels_enabled", [True, False]) +def test_drop_version_from_package( + release_channels_enabled, mock_use_role, mock_execute_query +): + action = "deregister" if release_channels_enabled else "drop" + package_name = "test_package" + version = "v1" + role = "test_role" + + expected_use_objects = [ + (mock_use_role, mock.call(role)), + ] + expected_execute_query = [ + ( + mock_execute_query, + mock.call( + f"alter application package {package_name} {action} version {version}" + ), + ), + ] + + with mock_release_channels(sql_facade, release_channels_enabled): + with assert_in_context(expected_use_objects, expected_execute_query): + sql_facade.drop_version_from_package( + package_name=package_name, version=version, role=role + ) + + +@pytest.mark.parametrize("release_channels_enabled", [True, False]) +def test_drop_version_from_package_with_special_characters( + release_channels_enabled, mock_use_role, mock_execute_query +): + action = "deregister" if release_channels_enabled else "drop" + package_name = "test.package" + version = "v1.0" + role = "test_role" + + expected_use_objects = [ + (mock_use_role, mock.call(role)), + ] + expected_execute_query = [ + ( + mock_execute_query, + mock.call( + f'alter application package "{package_name}" {action} version "{version}"' + ), + ), + ] + + with mock_release_channels(sql_facade, release_channels_enabled): + with assert_in_context(expected_use_objects, expected_execute_query): + sql_facade.drop_version_from_package( + package_name=package_name, version=version, role=role + ) + + +@pytest.mark.parametrize("available_release_channels", [[], [{"name": "test_channel"}]]) +def test_drop_version_from_package_with_error( + available_release_channels, mock_use_role, mock_execute_query +): + package_name = "test_package" + version = "v1" + role = "test_role" + + mock_execute_query.side_effect = ProgrammingError() + + with mock_release_channels(sql_facade, bool(available_release_channels)): + + with pytest.raises(InvalidSQLError): + sql_facade.drop_version_from_package( + package_name=package_name, version=version, role=role + ) diff --git a/tests/nativeapp/test_version_create.py b/tests/nativeapp/test_version_create.py index e035d50b65..fe70c0d6d6 100644 --- a/tests/nativeapp/test_version_create.py +++ b/tests/nativeapp/test_version_create.py @@ -45,6 +45,7 @@ APPLICATION_PACKAGE_ENTITY_MODULE, SQL_EXECUTOR_EXECUTE, SQL_FACADE, + SQL_FACADE_CREATE_VERSION, mock_execute_helper, mock_snowflake_yml_file_v2, ) @@ -134,38 +135,18 @@ def test_get_existing_release_direction_info( # Test add_new_version adds a new version to an app pkg correctly -@mock.patch(SQL_EXECUTOR_EXECUTE) +@mock.patch(SQL_FACADE_CREATE_VERSION) @pytest.mark.parametrize( - ["version", "version_identifier"], - [("V1", "V1"), ("1.0.0", '"1.0.0"'), ('"1.0.0"', '"1.0.0"')], + "version", + ["V1", "1.0.0", '"1.0.0"'], ) def test_add_version( - mock_execute, temp_dir, mock_cursor, version, version_identifier, workspace_context + mock_create_version, + temp_dir, + mock_cursor, + version, + workspace_context, ): - side_effects, expected = mock_execute_helper( - [ - ( - mock_cursor([("old_role",)], []), - mock.call("select current_role()"), - ), - (None, mock.call("use role package_role")), - ( - None, - mock.call( - dedent( - f"""\ - alter application package app_pkg - add version {version_identifier} - using @app_pkg.app_src.stage - """ - ), - ), - ), - (None, mock.call("use role old_role")), - ] - ) - mock_execute.side_effect = side_effects - current_working_directory = os.getcwd() create_named_file( file_name="snowflake.yml", @@ -178,7 +159,14 @@ def test_add_version( pkg_model: ApplicationPackageEntityModel = pd.entities["app_pkg"] pkg = ApplicationPackageEntity(pkg_model, workspace_context) pkg.add_new_version(version=version) - assert mock_execute.mock_calls == expected + + mock_create_version.assert_called_once_with( + package_name="app_pkg", + version=version, + stage_fqn=f"app_pkg.{pkg_model.stage}", + role="package_role", + label=None, + ) # Test add_new_patch_to_version adds an "auto-increment" patch to an existing version @@ -226,7 +214,9 @@ def test_add_new_patch_auto( pd = dm.project_definition pkg_model: ApplicationPackageEntityModel = pd.entities["app_pkg"] pkg = ApplicationPackageEntity(pkg_model, workspace_context) - pkg.add_new_patch_to_version(version=version) + result_patch = pkg.add_new_patch_to_version(version=version) + assert result_patch == 12 + assert mock_execute.mock_calls == expected @@ -275,7 +265,8 @@ def test_add_new_patch_custom( pd = dm.project_definition pkg_model: ApplicationPackageEntityModel = pd.entities["app_pkg"] pkg = ApplicationPackageEntity(pkg_model, workspace_context) - pkg.add_new_patch_to_version(version=version, patch=12) + result_patch = pkg.add_new_patch_to_version(version=version, patch=12) + assert result_patch == 12 assert mock_execute.mock_calls == expected @@ -429,13 +420,16 @@ def test_process_no_existing_release_directives_or_versions( contents=[mock_snowflake_yml_file_v2], ) - _version_create( + result = _version_create( version=version, patch=None, force=force, interactive=interactive, skip_git_check=False, ) # last three parameters do not matter here + + assert result == VersionInfo(version, 0, None) + mock_find_version.assert_not_called() mock_check_git.assert_called_once() mock_rd.assert_called_once() @@ -460,9 +454,7 @@ def test_process_no_existing_release_directives_or_versions( ) @mock.patch.object(ApplicationPackageEntity, "get_existing_version_info") @mock.patch.object(ApplicationPackageEntity, "add_new_version") -@mock.patch.object( - ApplicationPackageEntity, "add_new_patch_to_version", return_value=None -) +@mock.patch.object(ApplicationPackageEntity, "add_new_patch_to_version") @pytest.mark.parametrize("force", [True, False]) @pytest.mark.parametrize("interactive", [True, False]) def test_process_no_existing_release_directives_w_existing_version( @@ -493,14 +485,18 @@ def test_process_no_existing_release_directives_w_existing_version( dir_name=current_working_directory, contents=[mock_snowflake_yml_file_v2], ) + mock_add_patch.return_value = 12 - _version_create( + result = _version_create( version=version, patch=12, force=force, interactive=interactive, skip_git_check=False, ) # last three parameters do not matter here + + assert result == VersionInfo(version, 12, None) + mock_find_version.assert_not_called() mock_check_git.assert_called_once() mock_rd.assert_called_once() @@ -587,9 +583,7 @@ def test_process_existing_release_directives_user_does_not_proceed( @mock.patch.object( ApplicationPackageEntity, "get_existing_version_info", return_value=None ) -@mock.patch.object( - ApplicationPackageEntity, "add_new_patch_to_version", return_value=None -) +@mock.patch.object(ApplicationPackageEntity, "add_new_patch_to_version") @mock.patch.object(typer, "confirm", return_value=True) @pytest.mark.parametrize( "force, interactive", @@ -629,14 +623,18 @@ def test_process_existing_release_directives_w_existing_version_two( dir_name=current_working_directory, contents=[mock_snowflake_yml_file_v2], ) + mock_add_patch.return_value = 12 - _version_create( + result = _version_create( version=version, patch=12, force=force, interactive=interactive, skip_git_check=False, ) + + assert result == VersionInfo(version, 12, None) + mock_check_git.assert_called_once() mock_rd.assert_called_once() mock_deploy.assert_called_once() @@ -687,7 +685,7 @@ def test_manifest_version_info_not_used( ) ) - _version_create( + result = _version_create( version=version_cli, patch=None, label=None, @@ -696,12 +694,14 @@ def test_manifest_version_info_not_used( force=False, ) + assert result == VersionInfo(version_cli, 0, None) + mock_create_version.assert_called_with( role=role, package_name="app_pkg", stage_fqn=f"app_pkg.{stage}", version=version_cli, - label="", + label=None, ) mock_find_info_manifest.assert_not_called() @@ -724,7 +724,6 @@ def test_manifest_version_info_not_used( ) @mock.patch( f"{SQL_FACADE}.add_patch_to_package_version", - return_value=None, ) @pytest.mark.parametrize("label", [None, "some label"]) @pytest.mark.parametrize("patch", [None, 2, 7]) @@ -752,8 +751,9 @@ def test_manifest_patch_is_not_used( ), ) ) + mock_create_patch.return_value = patch or 0 - _version_create( + result = _version_create( version=version_cli, patch=patch, label=label, @@ -762,6 +762,8 @@ def test_manifest_patch_is_not_used( force=False, ) + assert result == VersionInfo(version_cli, patch or 0, label) + mock_create_patch.assert_called_with( role=role, package_name="app_pkg", @@ -769,7 +771,7 @@ def test_manifest_patch_is_not_used( version=version_cli, patch=patch, # ensure empty label is used to replace label from manifest.yml - label=label or "", + label=label, ) mock_find_info_manifest.assert_not_called() @@ -791,7 +793,6 @@ def test_manifest_patch_is_not_used( ) @mock.patch( f"{SQL_FACADE}.add_patch_to_package_version", - return_value=None, ) @pytest.mark.parametrize("manifest_label", [None, "some label", ""]) @pytest.mark.parametrize("manifest_patch", [None, 4]) @@ -826,9 +827,10 @@ def test_version_from_manifest( ), ) ) + mock_create_patch.return_value = manifest_patch # no version or patch through cli - _version_create( + result = _version_create( version=None, patch=None, label=cli_label, @@ -836,6 +838,9 @@ def test_version_from_manifest( interactive=True, force=False, ) + expected_label = cli_label if cli_label is not None else manifest_label + + assert result == VersionInfo("manifest_version", manifest_patch, expected_label) mock_create_patch.assert_called_with( role=role, @@ -843,7 +848,7 @@ def test_version_from_manifest( stage_fqn=f"app_pkg.{stage}", version="manifest_version", patch=manifest_patch, - label=cli_label if cli_label is not None else manifest_label, + label=expected_label, ) @@ -864,7 +869,6 @@ def test_version_from_manifest( ) @mock.patch( f"{SQL_FACADE}.add_patch_to_package_version", - return_value=None, ) @pytest.mark.parametrize("manifest_label", [None, "some label", ""]) @pytest.mark.parametrize("cli_label", [None, "", "cli label"]) @@ -898,9 +902,10 @@ def test_patch_from_manifest( ), ) ) + mock_create_patch.return_value = cli_patch # patch through cli, but no version - _version_create( + result = _version_create( version=None, patch=cli_patch, label=cli_label, @@ -911,6 +916,9 @@ def test_patch_from_manifest( console=mock_console, ) + expected_label = cli_label if cli_label is not None else manifest_label + assert result == VersionInfo("manifest_version", cli_patch, expected_label) + mock_create_patch.assert_called_with( role=role, package_name="app_pkg", @@ -918,7 +926,7 @@ def test_patch_from_manifest( version="manifest_version", # cli patch overrides the manifest patch=cli_patch, - label=cli_label if cli_label is not None else manifest_label, + label=expected_label, ) mock_console.warning.assert_called_with( f"Cannot resolve version. Found patch: {manifest_patch} in manifest.yml which is different from provided patch {cli_patch}." diff --git a/tests/nativeapp/test_version_drop.py b/tests/nativeapp/test_version_drop.py index 0877f4c66a..ba94fbdda2 100644 --- a/tests/nativeapp/test_version_drop.py +++ b/tests/nativeapp/test_version_drop.py @@ -39,9 +39,8 @@ from tests.nativeapp.utils import ( APP_PACKAGE_ENTITY_GET_EXISTING_APP_PKG_INFO, APPLICATION_PACKAGE_ENTITY_MODULE, - SQL_EXECUTOR_EXECUTE, + SQL_FACADE_DROP_VERSION, TYPER_CONFIRM, - mock_execute_helper, mock_snowflake_yml_file_v2, ) from tests.testing_utils.files_and_dirs import create_named_file @@ -199,41 +198,23 @@ def test_process_drop_cannot_complete( f"{APPLICATION_PACKAGE_ENTITY_MODULE}.find_version_info_in_manifest_file", return_value=VersionInfo("manifest_version", None, None), ) -@mock.patch(SQL_EXECUTOR_EXECUTE) @mock.patch( f"snowflake.cli._plugins.nativeapp.policy.{TYPER_CONFIRM}", return_value=True ) +@mock.patch(SQL_FACADE_DROP_VERSION) @pytest.mark.parametrize("force", [True, False]) def test_process_drop_from_manifest( + mock_drop_version, mock_typer_confirm, - mock_execute, mock_version_info_in_manifest, mock_build_bundle, mock_distribution, mock_get_existing, force, temp_dir, - mock_cursor, ): mock_distribution.return_value = "internal" - side_effects, expected = mock_execute_helper( - [ - ( - mock_cursor([("old_role",)], []), - mock.call("select current_role()"), - ), - (None, mock.call("use role package_role")), - ( - None, - mock.call( - "alter application package app_pkg drop version manifest_version" - ), - ), - (None, mock.call("use role old_role")), - ] - ) - mock_execute.side_effect = side_effects current_working_directory = os.getcwd() create_named_file( @@ -243,7 +224,10 @@ def test_process_drop_from_manifest( ) _drop_version(version=None, force=force, interactive=True) - assert mock_execute.mock_calls == expected + + mock_drop_version.assert_called_once_with( + package_name="app_pkg", version="manifest_version", role="package_role" + ) @mock.patch( @@ -255,46 +239,28 @@ def test_process_drop_from_manifest( f"{APPLICATION_PACKAGE_ENTITY_MODULE}.ApplicationPackageEntity._bundle", return_value=None, ) -@mock.patch(SQL_EXECUTOR_EXECUTE) @mock.patch( f"snowflake.cli._plugins.nativeapp.policy.{TYPER_CONFIRM}", return_value=True ) +@mock.patch(SQL_FACADE_DROP_VERSION) @pytest.mark.parametrize("force", [True, False]) @pytest.mark.parametrize( ["version", "version_identifier"], [("V1", "V1"), ("1.0.0", '"1.0.0"'), ('"1.0.0"', '"1.0.0"')], ) def test_process_drop_specific_version( + mock_drop_version, mock_typer_confirm, - mock_execute, mock_build_bundle, mock_distribution, mock_get_existing, force, temp_dir, - mock_cursor, version, version_identifier, ): mock_distribution.return_value = "internal" - side_effects, expected = mock_execute_helper( - [ - ( - mock_cursor([("old_role",)], []), - mock.call("select current_role()"), - ), - (None, mock.call("use role package_role")), - ( - None, - mock.call( - f"alter application package app_pkg drop version {version_identifier}" - ), - ), - (None, mock.call("use role old_role")), - ] - ) - mock_execute.side_effect = side_effects current_working_directory = os.getcwd() create_named_file( @@ -304,4 +270,7 @@ def test_process_drop_specific_version( ) _drop_version(version=version, force=force, interactive=True) - assert mock_execute.mock_calls == expected + + mock_drop_version.assert_called_once_with( + package_name="app_pkg", version=version_identifier, role="package_role" + ) diff --git a/tests/nativeapp/utils.py b/tests/nativeapp/utils.py index 7b9db67291..dcaae91080 100644 --- a/tests/nativeapp/utils.py +++ b/tests/nativeapp/utils.py @@ -94,6 +94,8 @@ SQL_FACADE_MODIFY_RELEASE_DIRECTIVE = f"{SQL_FACADE}.modify_release_directive" SQL_FACADE_UNSET_RELEASE_DIRECTIVE = f"{SQL_FACADE}.unset_release_directive" SQL_FACADE_SHOW_RELEASE_CHANNELS = f"{SQL_FACADE}.show_release_channels" +SQL_FACADE_DROP_VERSION = f"{SQL_FACADE}.drop_version_from_package" +SQL_FACADE_CREATE_VERSION = f"{SQL_FACADE}.create_version_in_package" mock_snowflake_yml_file = dedent( """\ diff --git a/tests_integration/nativeapp/__snapshots__/test_version.ambr b/tests_integration/nativeapp/__snapshots__/test_version.ambr index fcc3c0c6bc..4a7e2bde70 100644 --- a/tests_integration/nativeapp/__snapshots__/test_version.ambr +++ b/tests_integration/nativeapp/__snapshots__/test_version.ambr @@ -3,7 +3,7 @@ list([ dict({ 'comment': None, - 'label': '', + 'label': None, 'patch': 0, 'review_status': 'NOT_REVIEWED', 'state': 'READY', @@ -11,7 +11,7 @@ }), dict({ 'comment': None, - 'label': '', + 'label': None, 'patch': 1, 'review_status': 'NOT_REVIEWED', 'state': 'READY', diff --git a/tests_integration/nativeapp/test_version.py b/tests_integration/nativeapp/test_version.py index 61d8fb5fe9..3214457102 100644 --- a/tests_integration/nativeapp/test_version.py +++ b/tests_integration/nativeapp/test_version.py @@ -567,3 +567,73 @@ def test_nativeapp_version_create_quoted_identifiers( actual = runner.invoke_with_connection_json(["app", "version", "list"]) assert len(actual.json) == 0 + + +@pytest.mark.integration +def test_version_create_with_json_result(runner, nativeapp_project_directory): + with nativeapp_project_directory("napp_init_v2"): + result = runner.invoke_with_connection_json( + ["app", "version", "create", "v1", "--force", "--skip-git-check"] + ) + assert result.exit_code == 0 + assert result.json == { + "version": "v1", + "patch": 0, + "label": None, + "message": "Version create is now complete.", + } + + result = runner.invoke_with_connection_json( + [ + "app", + "version", + "create", + "v1", + "--force", + "--skip-git-check", + "--label", + "test", + ] + ) + assert result.exit_code == 0 + assert result.json == { + "version": "v1", + "patch": 1, + "label": "test", + "message": "Version create is now complete.", + } + + # try with custom patch: + result = runner.invoke_with_connection_json( + [ + "app", + "version", + "create", + "v1", + "--force", + "--skip-git-check", + "--patch", + 3, + "--label", + "", + ] + ) + assert result.exit_code == 0 + assert result.json == { + "version": "v1", + "patch": 3, + "label": "", + "message": "Version create is now complete.", + } + + # create version with special characters: + result = runner.invoke_with_connection_json( + ["app", "version", "create", "v1.1", "--force", "--skip-git-check"] + ) + assert result.exit_code == 0 + assert result.json == { + "version": '"v1.1"', + "patch": 0, + "label": None, + "message": "Version create is now complete.", + } From 9305955fb0783fcdf1d49cd7f4ca3f9b0050d00e Mon Sep 17 00:00:00 2001 From: Patryk Czajka Date: Thu, 12 Dec 2024 16:44:52 +0100 Subject: [PATCH 4/5] Temporarily disable schema existence check for object create (#1949) * Temporarily disable schema existance check for object create * skip test --- src/snowflake/cli/api/rest_api.py | 5 +++-- tests_integration/test_object.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/snowflake/cli/api/rest_api.py b/src/snowflake/cli/api/rest_api.py index ae829645b8..0125d51428 100644 --- a/src/snowflake/cli/api/rest_api.py +++ b/src/snowflake/cli/api/rest_api.py @@ -155,8 +155,9 @@ def determine_url_for_create_query( raise SchemaNotDefinedException( "Schema not defined in connection. Please try again with `--schema` flag." ) - if not self._schema_exists(db_name=db, schema_name=schema): - raise SchemaNotExistsException(f"Schema '{schema}' does not exist.") + # temporarily disable this check due to an issue on server side: SNOW-1747450 + # if not self._schema_exists(db_name=db, schema_name=schema): + # raise SchemaNotExistsException(f"Schema '{schema}' does not exist.") if self.get_endpoint_exists( url := f"{SF_REST_API_URL_PREFIX}/databases/{self.conn.database}/schemas/{self.conn.schema}/{plural_object_type}/" ): diff --git a/tests_integration/test_object.py b/tests_integration/test_object.py index 386150438b..9dbe526125 100644 --- a/tests_integration/test_object.py +++ b/tests_integration/test_object.py @@ -313,6 +313,7 @@ def test_create_error_database_not_exist(runner): @pytest.mark.integration +@pytest.mark.skip(reason="Server-side issue: SNOW-1855040") def test_create_error_schema_not_exist(runner, test_database): # schema does not exist result = runner.invoke_with_connection( From 5a3e36ee378c955a6079f37dbcd376698c5942e9 Mon Sep 17 00:00:00 2001 From: Guy Bloom Date: Thu, 12 Dec 2024 10:51:52 -0500 Subject: [PATCH 5/5] POC: Add child entities to application package (#1856) * add child entities * children_artifacts_dir * unit tests * sanitize dir name docstring * error message on child directory collision --- .../cli/_plugins/nativeapp/commands.py | 5 +- .../nativeapp/entities/application_package.py | 181 ++++++++++++++++-- .../application_package_child_interface.py | 43 +++++ .../cli/_plugins/nativeapp/feature_flags.py | 1 + src/snowflake/cli/_plugins/nativeapp/utils.py | 11 ++ .../nativeapp/v2_conversions/compat.py | 6 +- .../_plugins/streamlit/streamlit_entity.py | 64 ++++++- .../cli/_plugins/workspace/manager.py | 12 +- src/snowflake/cli/api/entities/common.py | 4 + .../api/project/schemas/project_definition.py | 33 +++- .../test_application_package_entity.py | 4 +- tests/nativeapp/test_children.py | 152 +++++++++++++++ tests/nativeapp/test_manager.py | 7 +- tests/streamlit/test_streamlit_entity.py | 53 +++++ .../projects/napp_children/app/README.md | 1 + .../projects/napp_children/app/manifest.yml | 7 + .../napp_children/app/setup_script.sql | 3 + .../projects/napp_children/snowflake.yml | 21 ++ .../projects/napp_children/streamlit_app.py | 20 ++ 19 files changed, 598 insertions(+), 30 deletions(-) create mode 100644 src/snowflake/cli/_plugins/nativeapp/entities/application_package_child_interface.py create mode 100644 tests/nativeapp/test_children.py create mode 100644 tests/streamlit/test_streamlit_entity.py create mode 100644 tests/test_data/projects/napp_children/app/README.md create mode 100644 tests/test_data/projects/napp_children/app/manifest.yml create mode 100644 tests/test_data/projects/napp_children/app/setup_script.sql create mode 100644 tests/test_data/projects/napp_children/snowflake.yml create mode 100644 tests/test_data/projects/napp_children/streamlit_app.py diff --git a/src/snowflake/cli/_plugins/nativeapp/commands.py b/src/snowflake/cli/_plugins/nativeapp/commands.py index 2d5bcbf901..411067ac5e 100644 --- a/src/snowflake/cli/_plugins/nativeapp/commands.py +++ b/src/snowflake/cli/_plugins/nativeapp/commands.py @@ -362,7 +362,10 @@ def app_validate( if cli_context.output_format == OutputFormat.JSON: return ObjectResult( package.get_validation_result( - use_scratch_stage=True, interactive=False, force=True + action_ctx=ws.action_ctx, + use_scratch_stage=True, + interactive=False, + force=True, ) ) diff --git a/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py b/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py index 54c643a628..8f76c7ac4c 100644 --- a/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py +++ b/src/snowflake/cli/_plugins/nativeapp/entities/application_package.py @@ -1,10 +1,11 @@ from __future__ import annotations import json +import os import re from pathlib import Path from textwrap import dedent -from typing import Any, List, Literal, Optional, Union +from typing import Any, List, Literal, Optional, Set, Union import typer from click import BadOptionUsage, ClickException @@ -14,6 +15,7 @@ BundleMap, VersionInfo, build_bundle, + find_setup_script_file, find_version_info_in_manifest_file, ) from snowflake.cli._plugins.nativeapp.bundle_context import BundleContext @@ -30,6 +32,9 @@ PATCH_COL, VERSION_COL, ) +from snowflake.cli._plugins.nativeapp.entities.application_package_child_interface import ( + ApplicationPackageChildInterface, +) from snowflake.cli._plugins.nativeapp.exceptions import ( ApplicationPackageAlreadyExistsError, ApplicationPackageDoesNotExistError, @@ -48,9 +53,16 @@ from snowflake.cli._plugins.nativeapp.sf_facade_exceptions import ( InsufficientPrivilegesError, ) -from snowflake.cli._plugins.nativeapp.utils import needs_confirmation +from snowflake.cli._plugins.nativeapp.utils import needs_confirmation, sanitize_dir_name +from snowflake.cli._plugins.snowpark.snowpark_entity_model import ( + FunctionEntityModel, + ProcedureEntityModel, +) from snowflake.cli._plugins.stage.diff import DiffResult from snowflake.cli._plugins.stage.manager import StageManager +from snowflake.cli._plugins.streamlit.streamlit_entity_model import ( + StreamlitEntityModel, +) from snowflake.cli._plugins.workspace.context import ActionContext from snowflake.cli.api.cli_global_context import span from snowflake.cli.api.entities.common import ( @@ -75,6 +87,7 @@ from snowflake.cli.api.project.schemas.updatable_model import ( DiscriminatorField, IdentifierField, + UpdatableModel, ) from snowflake.cli.api.project.schemas.v1.native_app.package import DistributionOptions from snowflake.cli.api.project.schemas.v1.native_app.path_mapping import PathMapping @@ -94,6 +107,43 @@ from snowflake.connector import DictCursor, ProgrammingError from snowflake.connector.cursor import SnowflakeCursor +ApplicationPackageChildrenTypes = ( + StreamlitEntityModel | FunctionEntityModel | ProcedureEntityModel +) + + +class ApplicationPackageChildIdentifier(UpdatableModel): + schema_: Optional[str] = Field( + title="Child entity schema", alias="schema", default=None + ) + + +class EnsureUsableByField(UpdatableModel): + application_roles: Optional[Union[str, Set[str]]] = Field( + title="One or more application roles to be granted with the required privileges", + default=None, + ) + + @field_validator("application_roles") + @classmethod + def ensure_app_roles_is_a_set( + cls, application_roles: Optional[Union[str, Set[str]]] + ) -> Optional[Union[Set[str]]]: + if isinstance(application_roles, str): + return set([application_roles]) + return application_roles + + +class ApplicationPackageChildField(UpdatableModel): + target: str = Field(title="The key of the entity to include in this package") + ensure_usable_by: Optional[EnsureUsableByField] = Field( + title="Automatically grant the required privileges on the child object and its schema", + default=None, + ) + identifier: ApplicationPackageChildIdentifier = Field( + title="Entity identifier", default=None + ) + class ApplicationPackageEntityModel(EntityModelBase): type: Literal["application package"] = DiscriminatorField() # noqa: A003 @@ -101,23 +151,27 @@ class ApplicationPackageEntityModel(EntityModelBase): title="List of paths or file source/destination pairs to add to the deploy root", ) bundle_root: Optional[str] = Field( - title="Folder at the root of your project where artifacts necessary to perform the bundle step are stored.", + title="Folder at the root of your project where artifacts necessary to perform the bundle step are stored", default="output/bundle/", ) deploy_root: Optional[str] = Field( title="Folder at the root of your project where the build step copies the artifacts", default="output/deploy/", ) + children_artifacts_dir: Optional[str] = Field( + title="Folder under deploy_root where the child artifacts will be stored", + default="_children/", + ) generated_root: Optional[str] = Field( - title="Subdirectory of the deploy root where files generated by the Snowflake CLI will be written.", + title="Subdirectory of the deploy root where files generated by the Snowflake CLI will be written", default="__generated/", ) stage: Optional[str] = IdentifierField( - title="Identifier of the stage that stores the application artifacts.", + title="Identifier of the stage that stores the application artifacts", default="app_src.stage", ) scratch_stage: Optional[str] = IdentifierField( - title="Identifier of the stage that stores temporary scratch data used by the Snowflake CLI.", + title="Identifier of the stage that stores temporary scratch data used by the Snowflake CLI", default="app_src.stage_snowflake_cli_scratch", ) distribution: Optional[DistributionOptions] = Field( @@ -128,6 +182,19 @@ class ApplicationPackageEntityModel(EntityModelBase): title="Path to manifest.yml. Unused and deprecated starting with Snowflake CLI 3.2", default="", ) + children: Optional[List[ApplicationPackageChildField]] = Field( + title="Entities that will be bundled and deployed as part of this application package", + default=[], + ) + + @field_validator("children") + @classmethod + def verify_children_behind_flag( + cls, input_value: Optional[List[ApplicationPackageChildField]] + ) -> Optional[List[ApplicationPackageChildField]]: + if input_value and not FeatureFlag.ENABLE_NATIVE_APP_CHILDREN.is_enabled(): + raise AttributeError("Application package children are not supported yet") + return input_value @field_validator("identifier") @classmethod @@ -183,6 +250,10 @@ def project_root(self) -> Path: def deploy_root(self) -> Path: return self.project_root / self._entity_model.deploy_root + @property + def children_artifacts_deploy_root(self) -> Path: + return self.deploy_root / self._entity_model.children_artifacts_dir + @property def bundle_root(self) -> Path: return self.project_root / self._entity_model.bundle_root @@ -221,7 +292,7 @@ def post_deploy_hooks(self) -> list[PostDeployHook] | None: return model.meta and model.meta.post_deploy def action_bundle(self, action_ctx: ActionContext, *args, **kwargs): - return self._bundle() + return self._bundle(action_ctx) def action_deploy( self, @@ -237,6 +308,7 @@ def action_deploy( **kwargs, ): return self._deploy( + action_ctx=action_ctx, bundle_map=None, prune=prune, recursive=recursive, @@ -336,6 +408,7 @@ def action_validate( **kwargs, ): self.validate_setup_script( + action_ctx=action_ctx, use_scratch_stage=use_scratch_stage, interactive=interactive, force=force, @@ -390,7 +463,7 @@ def action_version_create( else: git_policy = AllowAlwaysPolicy() - bundle_map = self._bundle() + bundle_map = self._bundle(action_ctx) resolved_version, resolved_patch, resolved_label = self.resolve_version_info( version=version, patch=patch, @@ -404,6 +477,7 @@ def action_version_create( self.check_index_changes_in_git_repo(policy=policy, interactive=interactive) self._deploy( + action_ctx=action_ctx, bundle_map=bundle_map, prune=True, recursive=True, @@ -507,7 +581,7 @@ def action_version_drop( """ ) ) - self._bundle() + self._bundle(action_ctx) version_info = find_version_info_in_manifest_file(self.deploy_root) version = version_info.version_name if not version: @@ -692,7 +766,7 @@ def action_release_directive_unset( role=self.role, ) - def _bundle(self): + def _bundle(self, action_ctx: ActionContext = None): model = self._entity_model bundle_map = build_bundle(self.project_root, self.deploy_root, model.artifacts) bundle_context = BundleContext( @@ -705,10 +779,80 @@ def _bundle(self): ) compiler = NativeAppCompiler(bundle_context) compiler.compile_artifacts() + + if self._entity_model.children: + # Bundle children and append their SQL to setup script + # TODO Consider re-writing the logic below as a processor + children_sql = self._bundle_children(action_ctx=action_ctx) + setup_file_path = find_setup_script_file(deploy_root=self.deploy_root) + with open(setup_file_path, "r", encoding="utf-8") as file: + existing_setup_script = file.read() + if setup_file_path.is_symlink(): + setup_file_path.unlink() + with open(setup_file_path, "w", encoding="utf-8") as file: + file.write(existing_setup_script) + file.write("\n-- AUTO GENERATED CHILDREN SECTION\n") + file.write("\n".join(children_sql)) + file.write("\n") + return bundle_map + def _bundle_children(self, action_ctx: ActionContext) -> List[str]: + # Create _children directory + children_artifacts_dir = self.children_artifacts_deploy_root + os.makedirs(children_artifacts_dir) + children_sql = [] + for child in self._entity_model.children: + # Create child sub directory + child_artifacts_dir = children_artifacts_dir / sanitize_dir_name( + child.target + ) + try: + os.makedirs(child_artifacts_dir) + except FileExistsError: + raise ClickException( + f"Could not create sub-directory at {child_artifacts_dir}. Make sure child entity names do not collide with each other." + ) + child_entity: ApplicationPackageChildInterface = action_ctx.get_entity( + child.target + ) + child_entity.bundle(child_artifacts_dir) + app_role = ( + to_identifier( + child.ensure_usable_by.application_roles.pop() # TODO Support more than one application role + ) + if child.ensure_usable_by and child.ensure_usable_by.application_roles + else None + ) + child_schema = ( + to_identifier(child.identifier.schema_) + if child.identifier and child.identifier.schema_ + else None + ) + children_sql.append( + child_entity.get_deploy_sql( + artifacts_dir=child_artifacts_dir.relative_to(self.deploy_root), + schema=child_schema, + ) + ) + if app_role: + children_sql.append( + f"CREATE APPLICATION ROLE IF NOT EXISTS {app_role};" + ) + if child_schema: + children_sql.append( + f"GRANT USAGE ON SCHEMA {child_schema} TO APPLICATION ROLE {app_role};" + ) + children_sql.append( + child_entity.get_usage_grant_sql( + app_role=app_role, schema=child_schema + ) + ) + return children_sql + def _deploy( self, + action_ctx: ActionContext, bundle_map: BundleMap | None, prune: bool, recursive: bool, @@ -733,7 +877,7 @@ def _deploy( stage_fqn = stage_fqn or self.stage_fqn # 1. Create a bundle if one wasn't passed in - bundle_map = bundle_map or self._bundle() + bundle_map = bundle_map or self._bundle(action_ctx) # 2. Create an empty application package, if none exists try: @@ -765,6 +909,7 @@ def _deploy( if validate: self.validate_setup_script( + action_ctx=action_ctx, use_scratch_stage=False, interactive=interactive, force=force, @@ -1054,7 +1199,11 @@ def execute_post_deploy_hooks(self): ) def validate_setup_script( - self, use_scratch_stage: bool, interactive: bool, force: bool + self, + action_ctx: ActionContext, + use_scratch_stage: bool, + interactive: bool, + force: bool, ): workspace_ctx = self._workspace_ctx console = workspace_ctx.console @@ -1062,6 +1211,7 @@ def validate_setup_script( """Validates Native App setup script SQL.""" with console.phase(f"Validating Snowflake Native App setup script."): validation_result = self.get_validation_result( + action_ctx=action_ctx, use_scratch_stage=use_scratch_stage, force=force, interactive=interactive, @@ -1083,13 +1233,18 @@ def validate_setup_script( @span("validate_setup_script") def get_validation_result( - self, use_scratch_stage: bool, interactive: bool, force: bool + self, + action_ctx: ActionContext, + use_scratch_stage: bool, + interactive: bool, + force: bool, ): """Call system$validate_native_app_setup() to validate deployed Native App setup script.""" stage_fqn = self.stage_fqn if use_scratch_stage: stage_fqn = self.scratch_stage_fqn self._deploy( + action_ctx=action_ctx, bundle_map=None, prune=True, recursive=True, diff --git a/src/snowflake/cli/_plugins/nativeapp/entities/application_package_child_interface.py b/src/snowflake/cli/_plugins/nativeapp/entities/application_package_child_interface.py new file mode 100644 index 0000000000..c4f13871e4 --- /dev/null +++ b/src/snowflake/cli/_plugins/nativeapp/entities/application_package_child_interface.py @@ -0,0 +1,43 @@ +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Optional + + +class ApplicationPackageChildInterface(ABC): + @abstractmethod + def bundle(self, bundle_root=Path, *args, **kwargs) -> None: + """ + Bundles the entity artifacts into the provided root directory. Must not have any side-effects, such as deploying the artifacts into a stage, etc. + @param bundle_root: The directory where the bundle contents should be put. + """ + pass + + @abstractmethod + def get_deploy_sql( + self, + artifacts_dir: Path, + schema: Optional[str], + *args, + **kwargs, + ) -> str: + """ + Returns the SQL that would create the entity object. Must not execute the SQL or have any other side-effects. + @param artifacts_dir: Path to the child entity artifacts directory relative to the deploy root. + @param [Optional] schema: Schema to use when creating the object. + """ + pass + + @abstractmethod + def get_usage_grant_sql( + self, + app_role: str, + schema: Optional[str], + *args, + **kwargs, + ) -> str: + """ + Returns the SQL that would grant the required USAGE privilege to the provided application role on the entity object. Must not execute the SQL or have any other side-effects. + @param app_role: The application role to grant the privileges to. + @param [Optional] schema: The schema where the object was created. + """ + pass diff --git a/src/snowflake/cli/_plugins/nativeapp/feature_flags.py b/src/snowflake/cli/_plugins/nativeapp/feature_flags.py index dbc47e7483..dc7e93bf51 100644 --- a/src/snowflake/cli/_plugins/nativeapp/feature_flags.py +++ b/src/snowflake/cli/_plugins/nativeapp/feature_flags.py @@ -22,4 +22,5 @@ class FeatureFlag(FeatureFlagMixin): ENABLE_NATIVE_APP_PYTHON_SETUP = BooleanFlag( "ENABLE_NATIVE_APP_PYTHON_SETUP", False ) + ENABLE_NATIVE_APP_CHILDREN = BooleanFlag("ENABLE_NATIVE_APP_CHILDREN", False) ENABLE_RELEASE_CHANNELS = BooleanFlag("ENABLE_RELEASE_CHANNELS", None) diff --git a/src/snowflake/cli/_plugins/nativeapp/utils.py b/src/snowflake/cli/_plugins/nativeapp/utils.py index 87fa989d2a..fa2a4cebd5 100644 --- a/src/snowflake/cli/_plugins/nativeapp/utils.py +++ b/src/snowflake/cli/_plugins/nativeapp/utils.py @@ -96,3 +96,14 @@ def verify_no_directories(paths_to_sync: Iterable[Path]): def verify_exists(path: Path): if not path.exists(): raise ClickException(f"The following path does not exist: {path}") + + +def sanitize_dir_name(dir_name: str) -> str: + """ + Returns a string that is safe to use as a directory name. + For simplicity, this function is over restricitive: it strips non alphanumeric characters, + unless listed in the allow list. Additional characters can be allowed in the future, but + we need to be careful to consider both Unix/Windows directory naming rules. + """ + allowed_chars = [" ", "_"] + return "".join(char for char in dir_name if char in allowed_chars or char.isalnum()) diff --git a/src/snowflake/cli/_plugins/nativeapp/v2_conversions/compat.py b/src/snowflake/cli/_plugins/nativeapp/v2_conversions/compat.py index 93d60c2e2b..a72a12f68d 100644 --- a/src/snowflake/cli/_plugins/nativeapp/v2_conversions/compat.py +++ b/src/snowflake/cli/_plugins/nativeapp/v2_conversions/compat.py @@ -217,7 +217,11 @@ def wrapper(*args, **kwargs): entities_to_keep.add(app_definition.entity_id) kwargs["app_entity_id"] = app_definition.entity_id for entity_id in list(original_pdf.entities): - if entity_id not in entities_to_keep: + entity_type = original_pdf.entities[entity_id].type.lower() + if ( + entity_type in ["application", "application package"] + and entity_id not in entities_to_keep + ): # This happens after templates are rendered, # so we can safely remove the entity del original_pdf.entities[entity_id] diff --git a/src/snowflake/cli/_plugins/streamlit/streamlit_entity.py b/src/snowflake/cli/_plugins/streamlit/streamlit_entity.py index 6def772525..6b187ba54b 100644 --- a/src/snowflake/cli/_plugins/streamlit/streamlit_entity.py +++ b/src/snowflake/cli/_plugins/streamlit/streamlit_entity.py @@ -1,12 +1,72 @@ +from pathlib import Path +from typing import Optional + +from snowflake.cli._plugins.nativeapp.artifacts import build_bundle +from snowflake.cli._plugins.nativeapp.entities.application_package_child_interface import ( + ApplicationPackageChildInterface, +) +from snowflake.cli._plugins.nativeapp.feature_flags import FeatureFlag from snowflake.cli._plugins.streamlit.streamlit_entity_model import ( StreamlitEntityModel, ) from snowflake.cli.api.entities.common import EntityBase +from snowflake.cli.api.project.schemas.v1.native_app.path_mapping import PathMapping -class StreamlitEntity(EntityBase[StreamlitEntityModel]): +# WARNING: This entity is not implemented yet. The logic below is only for demonstrating the +# required interfaces for composability (used by ApplicationPackageEntity behind a feature flag). +class StreamlitEntity( + EntityBase[StreamlitEntityModel], ApplicationPackageChildInterface +): """ A Streamlit app. """ - pass + def __init__(self, *args, **kwargs): + if not FeatureFlag.ENABLE_NATIVE_APP_CHILDREN.is_enabled(): + raise NotImplementedError("Streamlit entity is not implemented yet") + super().__init__(*args, **kwargs) + + @property + def project_root(self) -> Path: + return self._workspace_ctx.project_root + + @property + def deploy_root(self) -> Path: + return self.project_root / "output" / "deploy" + + def action_bundle( + self, + *args, + **kwargs, + ): + return self.bundle() + + def bundle(self, bundle_root=None): + return build_bundle( + self.project_root, + bundle_root or self.deploy_root, + [ + PathMapping(src=str(artifact)) + for artifact in self._entity_model.artifacts + ], + ) + + def get_deploy_sql( + self, + artifacts_dir: Optional[Path] = None, + schema: Optional[str] = None, + ): + entity_id = self.entity_id + if artifacts_dir: + streamlit_name = f"{schema}.{entity_id}" if schema else entity_id + return f"CREATE OR REPLACE STREAMLIT {streamlit_name} FROM '{artifacts_dir}' MAIN_FILE='{self._entity_model.main_file}';" + else: + return f"CREATE OR REPLACE STREAMLIT {entity_id} MAIN_FILE='{self._entity_model.main_file}';" + + def get_usage_grant_sql(self, app_role: str, schema: Optional[str] = None): + entity_id = self.entity_id + streamlit_name = f"{schema}.{entity_id}" if schema else entity_id + return ( + f"GRANT USAGE ON STREAMLIT {streamlit_name} TO APPLICATION ROLE {app_role};" + ) diff --git a/src/snowflake/cli/_plugins/workspace/manager.py b/src/snowflake/cli/_plugins/workspace/manager.py index 25b56d542f..10d7fef9c7 100644 --- a/src/snowflake/cli/_plugins/workspace/manager.py +++ b/src/snowflake/cli/_plugins/workspace/manager.py @@ -1,3 +1,4 @@ +from functools import cached_property from pathlib import Path from typing import Dict @@ -58,10 +59,7 @@ def perform_action(self, entity_id: str, action: EntityActions, *args, **kwargs) """ entity = self.get_entity(entity_id) if entity.supports(action): - action_ctx = ActionContext( - get_entity=self.get_entity, - ) - return entity.perform(action, action_ctx, *args, **kwargs) + return entity.perform(action, self.action_ctx, *args, **kwargs) else: raise ValueError(f'This entity type does not support "{action.value}"') @@ -69,6 +67,12 @@ def perform_action(self, entity_id: str, action: EntityActions, *args, **kwargs) def project_root(self) -> Path: return self._project_root + @cached_property + def action_ctx(self) -> ActionContext: + return ActionContext( + get_entity=self.get_entity, + ) + def _get_default_role() -> str: role = default_role() diff --git a/src/snowflake/cli/api/entities/common.py b/src/snowflake/cli/api/entities/common.py index c7bd6bfb0f..c444dc0897 100644 --- a/src/snowflake/cli/api/entities/common.py +++ b/src/snowflake/cli/api/entities/common.py @@ -63,6 +63,10 @@ def __init__(self, entity_model: T, workspace_ctx: WorkspaceContext): self._entity_model = entity_model self._workspace_ctx = workspace_ctx + @property + def entity_id(self): + return self._entity_model.entity_id + @classmethod def get_entity_model_type(cls) -> Type[T]: """ diff --git a/src/snowflake/cli/api/project/schemas/project_definition.py b/src/snowflake/cli/api/project/schemas/project_definition.py index cda6ecd8eb..2b0f4f5cf0 100644 --- a/src/snowflake/cli/api/project/schemas/project_definition.py +++ b/src/snowflake/cli/api/project/schemas/project_definition.py @@ -15,12 +15,17 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Union +from types import UnionType +from typing import Any, Dict, List, Optional, Union, get_args, get_origin from packaging.version import Version from pydantic import Field, ValidationError, field_validator, model_validator from pydantic_core.core_schema import ValidationInfo from snowflake.cli._plugins.nativeapp.entities.application import ApplicationEntityModel +from snowflake.cli._plugins.nativeapp.entities.application_package import ( + ApplicationPackageChildrenTypes, + ApplicationPackageEntityModel, +) from snowflake.cli.api.project.errors import SchemaValidationError from snowflake.cli.api.project.schemas.entities.common import ( TargetField, @@ -159,6 +164,12 @@ def _validate_single_entity( target_object = entity.from_ target_type = target_object.get_type() cls._validate_target_field(target_key, target_type, entities) + elif entity.type == ApplicationPackageEntityModel.get_type(): + for child_entity in entity.children: + target_key = child_entity.target + cls._validate_target_field( + target_key, ApplicationPackageChildrenTypes, entities + ) @classmethod def _validate_target_field( @@ -168,11 +179,20 @@ def _validate_target_field( raise ValueError(f"No such target: {target_key}") # Validate the target type - actual_target_type = entities[target_key].__class__ - if target_type and target_type is not actual_target_type: - raise ValueError( - f"Target type mismatch. Expected {target_type.__name__}, got {actual_target_type.__name__}" - ) + if target_type: + actual_target_type = entities[target_key].__class__ + if get_origin(target_type) in (Union, UnionType): + if actual_target_type not in get_args(target_type): + expected_types_str = ", ".join( + [t.__name__ for t in get_args(target_type)] + ) + raise ValueError( + f"Target type mismatch. Expected one of [{expected_types_str}], got {actual_target_type.__name__}" + ) + elif target_type is not actual_target_type: + raise ValueError( + f"Target type mismatch. Expected {target_type.__name__}, got {actual_target_type.__name__}" + ) @model_validator(mode="before") @classmethod @@ -200,6 +220,7 @@ def apply_mixins(cls, data: Dict, info: ValidationInfo) -> Dict: mixin_defs=data["mixins"], ) entities[entity_name] = merged_values + return data @classmethod diff --git a/tests/nativeapp/test_application_package_entity.py b/tests/nativeapp/test_application_package_entity.py index 2a0e632a6d..0772a5ada0 100644 --- a/tests/nativeapp/test_application_package_entity.py +++ b/tests/nativeapp/test_application_package_entity.py @@ -45,8 +45,8 @@ ) -def _get_app_pkg_entity(project_directory): - with project_directory("workspaces_simple") as project_root: +def _get_app_pkg_entity(project_directory, test_dir="workspaces_simple"): + with project_directory(test_dir) as project_root: with Path(project_root / "snowflake.yml").open() as definition_file_path: project_definition = yaml.safe_load(definition_file_path) model = ApplicationPackageEntityModel( diff --git a/tests/nativeapp/test_children.py b/tests/nativeapp/test_children.py new file mode 100644 index 0000000000..fca85666e3 --- /dev/null +++ b/tests/nativeapp/test_children.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent + +import pytest +import yaml +from snowflake.cli._plugins.nativeapp.entities.application_package import ( + ApplicationPackageEntityModel, +) +from snowflake.cli._plugins.nativeapp.feature_flags import FeatureFlag +from snowflake.cli._plugins.streamlit.streamlit_entity import StreamlitEntity +from snowflake.cli._plugins.workspace.context import ActionContext +from snowflake.cli._plugins.workspace.manager import WorkspaceManager +from snowflake.cli.api.project.errors import SchemaValidationError +from snowflake.cli.api.project.schemas.project_definition import ( + DefinitionV20, +) + +from tests.testing_utils.mock_config import mock_config_key + + +def _get_app_pkg_entity(project_directory): + with project_directory("napp_children") as project_root: + with Path(project_root / "snowflake.yml").open() as definition_file_path: + project_definition = DefinitionV20(**yaml.safe_load(definition_file_path)) + wm = WorkspaceManager( + project_definition=project_definition, + project_root=project_root, + ) + pkg_entity = wm.get_entity("pkg") + streamlit_entity = wm.get_entity("my_streamlit") + action_ctx = ActionContext( + get_entity=lambda entity_id: streamlit_entity, + ) + return ( + pkg_entity, + action_ctx, + ) + + +def test_children_feature_flag_is_disabled(): + assert FeatureFlag.ENABLE_NATIVE_APP_CHILDREN.is_enabled() == False + with pytest.raises(AttributeError) as err: + ApplicationPackageEntityModel( + **{"type": "application package", "children": [{"target": "some_child"}]} + ) + assert str(err.value) == "Application package children are not supported yet" + + +def test_invalid_children_type(): + with mock_config_key("enable_native_app_children", True): + definition_input = { + "definition_version": "2", + "entities": { + "pkg": { + "type": "application package", + "artifacts": [], + "children": [ + { + # packages cannot contain other packages as children + "target": "pkg2" + } + ], + }, + "pkg2": { + "type": "application package", + "artifacts": [], + }, + }, + } + with pytest.raises(SchemaValidationError) as err: + DefinitionV20(**definition_input) + assert "Target type mismatch" in str(err.value) + + +def test_invalid_children_target(): + with mock_config_key("enable_native_app_children", True): + definition_input = { + "definition_version": "2", + "entities": { + "pkg": { + "type": "application package", + "artifacts": [], + "children": [ + { + # no such entity + "target": "sl" + } + ], + }, + }, + } + with pytest.raises(SchemaValidationError) as err: + DefinitionV20(**definition_input) + assert "No such target: sl" in str(err.value) + + +def test_valid_children(): + with mock_config_key("enable_native_app_children", True): + definition_input = { + "definition_version": "2", + "entities": { + "pkg": { + "type": "application package", + "artifacts": [], + "children": [{"target": "sl"}], + }, + "sl": {"type": "streamlit", "identifier": "my_streamlit"}, + }, + } + project_definition = DefinitionV20(**definition_input) + wm = WorkspaceManager( + project_definition=project_definition, + project_root="", + ) + child_entity_id = project_definition.entities["pkg"].children[0] + child_entity = wm.get_entity(child_entity_id.target) + assert child_entity.__class__ == StreamlitEntity + + +def test_children_bundle_with_custom_dir(project_directory): + with mock_config_key("enable_native_app_children", True): + app_pkg, action_ctx = _get_app_pkg_entity(project_directory) + bundle_result = app_pkg.action_bundle(action_ctx) + deploy_root = bundle_result.deploy_root() + + # Application package artifacts + assert (deploy_root / "README.md").exists() + assert (deploy_root / "manifest.yml").exists() + assert (deploy_root / "setup_script.sql").exists() + + # Child artifacts + assert ( + deploy_root / "_entities" / "my_streamlit" / "streamlit_app.py" + ).exists() + + # Generated setup script section + with open(deploy_root / "setup_script.sql", "r") as f: + setup_script_content = f.read() + custom_dir_path = Path("_entities", "my_streamlit") + assert setup_script_content.endswith( + dedent( + f""" + -- AUTO GENERATED CHILDREN SECTION + CREATE OR REPLACE STREAMLIT v_schema.my_streamlit FROM '{custom_dir_path}' MAIN_FILE='streamlit_app.py'; + CREATE APPLICATION ROLE IF NOT EXISTS my_app_role; + GRANT USAGE ON SCHEMA v_schema TO APPLICATION ROLE my_app_role; + GRANT USAGE ON STREAMLIT v_schema.my_streamlit TO APPLICATION ROLE my_app_role; + """ + ) + ) diff --git a/tests/nativeapp/test_manager.py b/tests/nativeapp/test_manager.py index 57cafe7a07..c61467c044 100644 --- a/tests/nativeapp/test_manager.py +++ b/tests/nativeapp/test_manager.py @@ -1376,6 +1376,7 @@ def test_validate_use_scratch_stage(mock_execute, mock_deploy, temp_dir, mock_cu pd = wm._project_definition # noqa: SLF001 pkg_model: ApplicationPackageEntityModel = pd.entities["app_pkg"] mock_deploy.assert_called_with( + action_ctx=wm.action_ctx, bundle_map=None, prune=True, recursive=True, @@ -1452,6 +1453,7 @@ def test_validate_failing_drops_scratch_stage( pd = wm._project_definition # noqa: SLF001 pkg_model: ApplicationPackageEntityModel = pd.entities["app_pkg"] mock_deploy.assert_called_with( + action_ctx=wm.action_ctx, bundle_map=None, prune=True, recursive=True, @@ -1511,7 +1513,10 @@ def test_validate_raw_returns_data(mock_execute, temp_dir, mock_cursor): pkg = wm.get_entity("app_pkg") assert ( pkg.get_validation_result( - use_scratch_stage=False, interactive=False, force=True + action_ctx=wm.action_ctx, + use_scratch_stage=False, + interactive=False, + force=True, ) == failure_data ) diff --git a/tests/streamlit/test_streamlit_entity.py b/tests/streamlit/test_streamlit_entity.py new file mode 100644 index 0000000000..315e34b8e5 --- /dev/null +++ b/tests/streamlit/test_streamlit_entity.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from snowflake.cli._plugins.streamlit.streamlit_entity import ( + StreamlitEntity, +) +from snowflake.cli._plugins.streamlit.streamlit_entity_model import ( + StreamlitEntityModel, +) +from snowflake.cli._plugins.workspace.context import WorkspaceContext +from snowflake.cli.api.console import cli_console as cc +from snowflake.cli.api.project.definition_manager import DefinitionManager + +from tests.testing_utils.mock_config import mock_config_key + + +def test_cannot_instantiate_without_feature_flag(): + with pytest.raises(NotImplementedError) as err: + StreamlitEntity() + assert str(err.value) == "Streamlit entity is not implemented yet" + + +def test_nativeapp_children_interface(temp_dir): + with mock_config_key("enable_native_app_children", True): + dm = DefinitionManager() + ctx = WorkspaceContext( + console=cc, + project_root=dm.project_root, + get_default_role=lambda: "mock_role", + get_default_warehouse=lambda: "mock_warehouse", + ) + main_file = "main.py" + (Path(temp_dir) / main_file).touch() + model = StreamlitEntityModel( + type="streamlit", + main_file=main_file, + artifacts=[main_file], + ) + sl = StreamlitEntity(model, ctx) + + sl.bundle() + bundle_artifact = Path(temp_dir) / "output" / "deploy" / main_file + deploy_sql_str = sl.get_deploy_sql() + grant_sql_str = sl.get_usage_grant_sql(app_role="app_role") + + assert bundle_artifact.exists() + assert deploy_sql_str == "CREATE OR REPLACE STREAMLIT None MAIN_FILE='main.py';" + assert ( + grant_sql_str + == "GRANT USAGE ON STREAMLIT None TO APPLICATION ROLE app_role;" + ) diff --git a/tests/test_data/projects/napp_children/app/README.md b/tests/test_data/projects/napp_children/app/README.md new file mode 100644 index 0000000000..7e59600739 --- /dev/null +++ b/tests/test_data/projects/napp_children/app/README.md @@ -0,0 +1 @@ +# README diff --git a/tests/test_data/projects/napp_children/app/manifest.yml b/tests/test_data/projects/napp_children/app/manifest.yml new file mode 100644 index 0000000000..0b8b9b892c --- /dev/null +++ b/tests/test_data/projects/napp_children/app/manifest.yml @@ -0,0 +1,7 @@ +# This is the v2 version of the napp_init_v1 project + +manifest_version: 1 + +artifacts: + setup_script: setup_script.sql + readme: README.md diff --git a/tests/test_data/projects/napp_children/app/setup_script.sql b/tests/test_data/projects/napp_children/app/setup_script.sql new file mode 100644 index 0000000000..ade6eccbd6 --- /dev/null +++ b/tests/test_data/projects/napp_children/app/setup_script.sql @@ -0,0 +1,3 @@ +CREATE OR ALTER VERSIONED SCHEMA v_schema; +CREATE APPLICATION ROLE IF NOT EXISTS my_app_role; +GRANT USAGE ON SCHEMA v_schema TO APPLICATION ROLE my_app_role; diff --git a/tests/test_data/projects/napp_children/snowflake.yml b/tests/test_data/projects/napp_children/snowflake.yml new file mode 100644 index 0000000000..52667820df --- /dev/null +++ b/tests/test_data/projects/napp_children/snowflake.yml @@ -0,0 +1,21 @@ +definition_version: 2 +entities: + pkg: + type: application package + identifier: my_pkg + artifacts: + - src: app/* + dest: ./ + children_artifacts_dir: _entities + children: + - target: my_streamlit + identifier: + schema: v_schema + ensure_usable_by: + application_roles: ["my_app_role"] + + my_streamlit: + type: streamlit + main_file: streamlit_app.py + artifacts: + - streamlit_app.py diff --git a/tests/test_data/projects/napp_children/streamlit_app.py b/tests/test_data/projects/napp_children/streamlit_app.py new file mode 100644 index 0000000000..45c8ad3822 --- /dev/null +++ b/tests/test_data/projects/napp_children/streamlit_app.py @@ -0,0 +1,20 @@ +from http.client import HTTPSConnection + +import _snowflake +import streamlit as st + + +def get_secret_value(): + return _snowflake.get_generic_secret_string("generic_secret") + + +def send_request(): + host = "docs.snowflake.com" + conn = HTTPSConnection(host) + conn.request("GET", "/") + response = conn.getresponse() + st.success(f"Response status: {response.status}") + + +st.title(f"Example streamlit app.") +st.button("Send request", on_click=send_request)