diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index ab550b646f..1f002a8c12 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -23,11 +23,14 @@ * `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 -* Added a feature flag for log streaming to support staged rollouts +* Fixes label being set to blank instead of None when not provided. +* Added a feature flag `ENABLE_SPCS_LOG_STREAMING` to control the rollout of the log streaming feature # v3.2.0 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 35d5963f73..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, @@ -370,7 +443,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. @@ -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, @@ -453,12 +527,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, @@ -505,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: @@ -537,14 +613,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." @@ -695,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( @@ -708,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, @@ -736,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: @@ -768,6 +909,7 @@ def _deploy( if validate: self.validate_setup_script( + action_ctx=action_ctx, use_scratch_stage=False, interactive=interactive, force=force, @@ -846,9 +988,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 +1011,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 @@ -1055,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 @@ -1063,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, @@ -1084,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, @@ -1134,7 +1288,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 +1296,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 +1311,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/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/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/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/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/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/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/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/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/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) 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.", + } 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(