snowflake-cli 3.2.2__py3-none-any.whl → 3.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. snowflake/cli/__about__.py +1 -1
  2. snowflake/cli/_app/constants.py +4 -0
  3. snowflake/cli/_app/snow_connector.py +12 -0
  4. snowflake/cli/_app/telemetry.py +10 -3
  5. snowflake/cli/_plugins/connection/util.py +12 -19
  6. snowflake/cli/_plugins/helpers/commands.py +207 -1
  7. snowflake/cli/_plugins/nativeapp/artifacts.py +10 -4
  8. snowflake/cli/_plugins/nativeapp/codegen/compiler.py +41 -17
  9. snowflake/cli/_plugins/nativeapp/codegen/setup/native_app_setup_processor.py +7 -0
  10. snowflake/cli/_plugins/nativeapp/codegen/snowpark/python_processor.py +4 -1
  11. snowflake/cli/_plugins/nativeapp/codegen/templates/templates_processor.py +42 -32
  12. snowflake/cli/_plugins/nativeapp/commands.py +92 -2
  13. snowflake/cli/_plugins/nativeapp/constants.py +5 -0
  14. snowflake/cli/_plugins/nativeapp/entities/application.py +221 -288
  15. snowflake/cli/_plugins/nativeapp/entities/application_package.py +772 -89
  16. snowflake/cli/_plugins/nativeapp/entities/application_package_child_interface.py +43 -0
  17. snowflake/cli/_plugins/nativeapp/feature_flags.py +5 -1
  18. snowflake/cli/_plugins/nativeapp/release_channel/__init__.py +13 -0
  19. snowflake/cli/_plugins/nativeapp/release_channel/commands.py +212 -0
  20. snowflake/cli/_plugins/nativeapp/release_directive/__init__.py +13 -0
  21. snowflake/cli/_plugins/nativeapp/release_directive/commands.py +165 -0
  22. snowflake/cli/_plugins/nativeapp/same_account_install_method.py +9 -17
  23. snowflake/cli/_plugins/nativeapp/sf_facade_exceptions.py +80 -0
  24. snowflake/cli/_plugins/nativeapp/sf_sql_facade.py +999 -75
  25. snowflake/cli/_plugins/nativeapp/utils.py +11 -0
  26. snowflake/cli/_plugins/nativeapp/v2_conversions/compat.py +5 -1
  27. snowflake/cli/_plugins/nativeapp/version/commands.py +31 -4
  28. snowflake/cli/_plugins/notebook/manager.py +4 -2
  29. snowflake/cli/_plugins/snowpark/snowpark_entity.py +234 -4
  30. snowflake/cli/_plugins/spcs/common.py +129 -0
  31. snowflake/cli/_plugins/spcs/services/commands.py +134 -14
  32. snowflake/cli/_plugins/spcs/services/manager.py +169 -1
  33. snowflake/cli/_plugins/stage/manager.py +12 -4
  34. snowflake/cli/_plugins/streamlit/manager.py +8 -1
  35. snowflake/cli/_plugins/streamlit/streamlit_entity.py +153 -2
  36. snowflake/cli/_plugins/workspace/commands.py +3 -2
  37. snowflake/cli/_plugins/workspace/manager.py +8 -4
  38. snowflake/cli/api/cli_global_context.py +22 -1
  39. snowflake/cli/api/config.py +6 -2
  40. snowflake/cli/api/connections.py +12 -1
  41. snowflake/cli/api/constants.py +9 -1
  42. snowflake/cli/api/entities/common.py +85 -0
  43. snowflake/cli/api/entities/utils.py +9 -8
  44. snowflake/cli/api/errno.py +60 -3
  45. snowflake/cli/api/feature_flags.py +20 -4
  46. snowflake/cli/api/metrics.py +21 -27
  47. snowflake/cli/api/project/definition_conversion.py +1 -2
  48. snowflake/cli/api/project/schemas/project_definition.py +27 -6
  49. snowflake/cli/api/project/schemas/v1/streamlit/streamlit.py +1 -1
  50. snowflake/cli/api/project/util.py +45 -0
  51. {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.3.0.dist-info}/METADATA +12 -12
  52. {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.3.0.dist-info}/RECORD +55 -50
  53. {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.3.0.dist-info}/WHEEL +1 -1
  54. {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.3.0.dist-info}/entry_points.txt +0 -0
  55. {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -17,6 +17,7 @@ from __future__ import annotations
17
17
  from contextlib import contextmanager
18
18
  from contextvars import ContextVar
19
19
  from dataclasses import dataclass, field, replace
20
+ from functools import wraps
20
21
  from pathlib import Path
21
22
  from typing import TYPE_CHECKING, Iterator
22
23
 
@@ -157,7 +158,7 @@ class _CliGlobalContextAccess:
157
158
  return self._manager.enable_tracebacks
158
159
 
159
160
  @property
160
- def metrics(self):
161
+ def metrics(self) -> CLIMetrics:
161
162
  return self._manager.metrics
162
163
 
163
164
  @property
@@ -213,6 +214,26 @@ def get_cli_context() -> _CliGlobalContextAccess:
213
214
  return _CliGlobalContextAccess(get_cli_context_manager())
214
215
 
215
216
 
217
+ def span(span_name: str):
218
+ """
219
+ Decorator to start a command metrics span that encompasses a whole function
220
+
221
+ Must be used instead of directly calling @get_cli_context().metrics.span(span_name)
222
+ as a decorator to ensure that the cli context is grabbed at run time instead of at
223
+ module load time, which would not reflect forking
224
+ """
225
+
226
+ def decorator(func):
227
+ @wraps(func)
228
+ def wrapper(*args, **kwargs):
229
+ with get_cli_context().metrics.span(span_name):
230
+ return func(*args, **kwargs)
231
+
232
+ return wrapper
233
+
234
+ return decorator
235
+
236
+
216
237
  @contextmanager
217
238
  def fork_cli_context(
218
239
  connection_overrides: dict | None = None,
@@ -286,8 +286,12 @@ def get_config_value(*path, key: str, default: Optional[Any] = Empty) -> Any:
286
286
  raise
287
287
 
288
288
 
289
- def get_config_bool_value(*path, key: str, default: Optional[Any] = Empty) -> bool:
290
- value = get_config_value(*path, key=key, default=default)
289
+ def get_config_bool_value(*path, key: str, default: Optional[bool]) -> Optional[bool]:
290
+ value = get_config_value(*path, key=key, default=None)
291
+
292
+ if value is None:
293
+ return default
294
+
291
295
  try:
292
296
  return try_cast_to_bool(value)
293
297
  except ValueError:
@@ -79,6 +79,17 @@ class ConnectionContext:
79
79
  raise KeyError(f"{key} is not a field of {self.__class__.__name__}")
80
80
  setattr(self, key, value)
81
81
 
82
+ def merge_with_config(self, **updates) -> ConnectionContext:
83
+ """
84
+ Updates missing fields from the config, but does not overwrite existing.
85
+ """
86
+ field_map = {field.name for field in fields(self)}
87
+ for key, value in updates.items():
88
+ if key in field_map and getattr(self, key) is None:
89
+ setattr(self, key, value)
90
+
91
+ return self
92
+
82
93
  def update_from_config(self) -> ConnectionContext:
83
94
  connection_config = get_connection_dict(connection_name=self.connection_name)
84
95
  if "private_key_path" in connection_config:
@@ -87,7 +98,7 @@ class ConnectionContext:
87
98
  ]
88
99
  del connection_config["private_key_path"]
89
100
 
90
- self.update(**connection_config)
101
+ self.merge_with_config(**connection_config)
91
102
  return self
92
103
 
93
104
  def __repr__(self) -> str:
@@ -62,6 +62,10 @@ class ObjectType(Enum):
62
62
  "image-repository", "image repository", "image repositories"
63
63
  )
64
64
  GIT_REPOSITORY = ObjectNames("git-repository", "git repository", "git repositories")
65
+ APPLICATION = ObjectNames("application", "application", "applications")
66
+ APPLICATION_PACKAGE = ObjectNames(
67
+ "application-package", "application package", "application packages"
68
+ )
65
69
 
66
70
  def __str__(self):
67
71
  """This makes using this Enum easier in formatted string"""
@@ -69,7 +73,11 @@ class ObjectType(Enum):
69
73
 
70
74
 
71
75
  OBJECT_TO_NAMES = {o.value.cli_name: o.value for o in ObjectType}
72
- SUPPORTED_OBJECTS = sorted(OBJECT_TO_NAMES.keys())
76
+ UNSUPPORTED_OBJECTS = {
77
+ ObjectType.APPLICATION.value.cli_name,
78
+ ObjectType.APPLICATION_PACKAGE.value.cli_name,
79
+ }
80
+ SUPPORTED_OBJECTS = sorted(OBJECT_TO_NAMES.keys() - UNSUPPORTED_OBJECTS)
73
81
 
74
82
  # Scope names here must replace spaces with '-'. For example 'compute pool' is 'compute-pool'.
75
83
  VALID_SCOPES = ["database", "schema", "compute-pool"]
@@ -1,8 +1,14 @@
1
+ import functools
1
2
  from enum import Enum
3
+ from pathlib import Path
2
4
  from typing import Generic, Type, TypeVar, get_args
3
5
 
4
6
  from snowflake.cli._plugins.workspace.context import ActionContext, WorkspaceContext
7
+ from snowflake.cli.api.cli_global_context import span
8
+ from snowflake.cli.api.identifiers import FQN
5
9
  from snowflake.cli.api.sql_execution import SqlExecutor
10
+ from snowflake.connector import SnowflakeConnection
11
+ from snowflake.connector.cursor import SnowflakeCursor
6
12
 
7
13
 
8
14
  class EntityActions(str, Enum):
@@ -16,10 +22,47 @@ class EntityActions(str, Enum):
16
22
  VERSION_CREATE = "action_version_create"
17
23
  VERSION_DROP = "action_version_drop"
18
24
 
25
+ RELEASE_DIRECTIVE_UNSET = "action_release_directive_unset"
26
+ RELEASE_DIRECTIVE_SET = "action_release_directive_set"
27
+ RELEASE_DIRECTIVE_LIST = "action_release_directive_list"
28
+
29
+ RELEASE_CHANNEL_LIST = "action_release_channel_list"
30
+ RELEASE_CHANNEL_ADD_ACCOUNTS = "action_release_channel_add_accounts"
31
+ RELEASE_CHANNEL_REMOVE_ACCOUNTS = "action_release_channel_remove_accounts"
32
+ RELEASE_CHANNEL_ADD_VERSION = "action_release_channel_add_version"
33
+ RELEASE_CHANNEL_REMOVE_VERSION = "action_release_channel_remove_version"
34
+
35
+ PUBLISH = "action_publish"
36
+
19
37
 
20
38
  T = TypeVar("T")
21
39
 
22
40
 
41
+ def attach_spans_to_entity_actions(entity_name: str):
42
+ """
43
+ Class decorator for EntityBase subclasses to automatically wrap
44
+ every implemented entity action method with a metrics span
45
+
46
+ Args:
47
+ entity_name (str): Custom name for entity type to be displayed in metrics
48
+ """
49
+
50
+ def decorator(cls: type[T]) -> type[T]:
51
+ for attr_name, attr_value in vars(cls).items():
52
+ is_entity_action = attr_name in [
53
+ enum_member for enum_member in EntityActions
54
+ ]
55
+
56
+ if is_entity_action and callable(attr_value):
57
+ attr_name_without_action_prefix = attr_name.partition("_")[2]
58
+ span_name = f"action.{entity_name}.{attr_name_without_action_prefix}"
59
+ action_with_span = span(span_name)(attr_value)
60
+ setattr(cls, attr_name, action_with_span)
61
+ return cls
62
+
63
+ return decorator
64
+
65
+
23
66
  class EntityBase(Generic[T]):
24
67
  """
25
68
  Base class for the fully-featured entity classes.
@@ -29,6 +72,10 @@ class EntityBase(Generic[T]):
29
72
  self._entity_model = entity_model
30
73
  self._workspace_ctx = workspace_ctx
31
74
 
75
+ @property
76
+ def entity_id(self) -> str:
77
+ return self._entity_model.entity_id # type: ignore
78
+
32
79
  @classmethod
33
80
  def get_entity_model_type(cls) -> Type[T]:
34
81
  """
@@ -52,6 +99,44 @@ class EntityBase(Generic[T]):
52
99
  """
53
100
  return getattr(self, action)(action_ctx, *args, **kwargs)
54
101
 
102
+ @property
103
+ def root(self) -> Path:
104
+ return self._workspace_ctx.project_root
105
+
106
+ @property
107
+ def identifier(self) -> str:
108
+ return self.model.fqn.sql_identifier
109
+
110
+ @property
111
+ def fqn(self) -> FQN:
112
+ return self._entity_model.fqn # type: ignore[attr-defined]
113
+
114
+ @functools.cached_property
115
+ def _sql_executor(
116
+ self,
117
+ ) -> SqlExecutor:
118
+ return get_sql_executor()
119
+
120
+ def _execute_query(self, sql: str) -> SnowflakeCursor:
121
+ return self._sql_executor.execute_query(sql)
122
+
123
+ @functools.cached_property
124
+ def _conn(self) -> SnowflakeConnection:
125
+ return self._sql_executor._conn # noqa
126
+
127
+ @property
128
+ def model(self):
129
+ return self._entity_model
130
+
131
+ def get_usage_grant_sql(self, app_role: str) -> str:
132
+ return f"GRANT USAGE ON {self.model.type.upper()} {self.identifier} TO ROLE {app_role};"
133
+
134
+ def get_describe_sql(self) -> str:
135
+ return f"DESCRIBE {self.model.type.upper()} {self.identifier};"
136
+
137
+ def get_drop_sql(self) -> str:
138
+ return f"DROP {self.model.type.upper()} {self.identifier};"
139
+
55
140
 
56
141
  def get_sql_executor() -> SqlExecutor:
57
142
  """Returns an SQL Executor that uses the connection from the current CLI context"""
@@ -23,7 +23,7 @@ from snowflake.cli._plugins.stage.diff import (
23
23
  to_stage_path,
24
24
  )
25
25
  from snowflake.cli._plugins.stage.utils import print_diff_to_console
26
- from snowflake.cli.api.cli_global_context import get_cli_context
26
+ from snowflake.cli.api.cli_global_context import get_cli_context, span
27
27
  from snowflake.cli.api.console.abc import AbstractConsole
28
28
  from snowflake.cli.api.entities.common import get_sql_executor
29
29
  from snowflake.cli.api.errno import (
@@ -42,7 +42,6 @@ from snowflake.cli.api.rendering.sql_templates import (
42
42
  )
43
43
  from snowflake.cli.api.secure_path import UNLIMITED, SecurePath
44
44
  from snowflake.connector import ProgrammingError
45
- from snowflake.connector.cursor import SnowflakeCursor
46
45
 
47
46
 
48
47
  def generic_sql_error_handler(err: ProgrammingError) -> NoReturn:
@@ -76,6 +75,7 @@ def _get_stage_paths_to_sync(
76
75
  return stage_paths
77
76
 
78
77
 
78
+ @span("sync_deploy_root_with_stage")
79
79
  def sync_deploy_root_with_stage(
80
80
  console: AbstractConsole,
81
81
  deploy_root: Path,
@@ -212,7 +212,10 @@ def execute_post_deploy_hooks(
212
212
 
213
213
  get_cli_context().metrics.set_counter(CLICounterField.POST_DEPLOY_SCRIPTS, 1)
214
214
 
215
- with console.phase(f"Executing {deployed_object_type} post-deploy actions"):
215
+ with (
216
+ console.phase(f"Executing {deployed_object_type} post-deploy actions"),
217
+ get_cli_context().metrics.span("post_deploy_hooks"),
218
+ ):
216
219
  sql_scripts_paths = []
217
220
  display_paths = []
218
221
  for hook in post_deploy_hooks:
@@ -321,17 +324,15 @@ def drop_generic_object(
321
324
  console.message(f"Dropped {object_type} {object_name} successfully.")
322
325
 
323
326
 
324
- def print_messages(
325
- console: AbstractConsole, create_or_upgrade_cursor: Optional[SnowflakeCursor]
326
- ):
327
+ def print_messages(console: AbstractConsole, cursor_results: list[tuple[str]]):
327
328
  """
328
329
  Shows messages in the console returned by the CREATE or UPGRADE
329
330
  APPLICATION command.
330
331
  """
331
- if not create_or_upgrade_cursor:
332
+ if not cursor_results:
332
333
  return
333
334
 
334
- messages = [row[0] for row in create_or_upgrade_cursor.fetchall()]
335
+ messages = [row[0] for row in cursor_results]
335
336
  for message in messages:
336
337
  console.warning(message)
337
338
  console.message("")
@@ -14,17 +14,74 @@
14
14
 
15
15
  # General errors
16
16
  NO_WAREHOUSE_SELECTED_IN_SESSION = 606
17
+ EMPTY_SQL_STATEMENT = 900
17
18
 
18
- DOES_NOT_EXIST_OR_NOT_AUTHORIZED = 2003
19
- DOES_NOT_EXIST_OR_CANNOT_BE_PERFORMED = 2043
20
- INSUFFICIENT_PRIVILEGES = 3001
19
+ SQL_COMPILATION_ERROR = 1003
20
+ OBJECT_ALREADY_EXISTS_IN_DOMAIN = 1998
21
+ OBJECT_ALREADY_EXISTS = 2002
22
+ DOES_NOT_EXIST_OR_NOT_AUTHORIZED = 2003 # BASE_TABLE_OR_VIEW_NOT_FOUND
23
+ DUPLICATE_COLUMN_NAME = 2025
24
+ VIEW_EXPANSION_FAILED = 2037
25
+ DOES_NOT_EXIST_OR_CANNOT_BE_PERFORMED = (
26
+ 2043 # OBJECT_DOES_NOT_EXIST_OR_CANNOT_PERFORM_OPERATION
27
+ )
28
+ INSUFFICIENT_PRIVILEGES = 3001 # NOT_AUTHORIZED
29
+ INVALID_OBJECT_TYPE_FOR_SPECIFIED_PRIVILEGE = 3008
30
+ ROLE_NOT_ASSIGNED = 3013
31
+ NO_INDIVIDUAL_PRIVS = 3028
32
+ OBJECT_ALREADY_EXISTS_NO_PRIVILEGES = 3041
21
33
 
22
34
  # Native Apps
35
+ APPLICATION_PACKAGE_MANIFEST_SPECIFIED_FILE_NOT_FOUND = 93003
36
+ APPLICATION_FILE_NOT_FOUND_ON_STAGE = 93009
37
+ CANNOT_GRANT_OBJECT_NOT_IN_APP_PACKAGE = 93011
38
+ CANNOT_GRANT_RESTRICTED_PRIVILEGE_TO_APP_PACKAGE_SHARE = 93012
39
+ APPLICATION_PACKAGE_VERSION_ALREADY_EXISTS = 93030
40
+ APPLICATION_PACKAGE_VERSION_NAME_TOO_LONG = 93035
41
+ APPLICATION_PACKAGE_PATCH_DOES_NOT_EXIST = 93036
42
+ APPLICATION_PACKAGE_MAX_VERSIONS_HIT = 93037
23
43
  CANNOT_UPGRADE_FROM_LOOSE_FILES_TO_VERSION = 93044
24
44
  CANNOT_UPGRADE_FROM_VERSION_TO_LOOSE_FILES = 93045
25
45
  ONLY_SUPPORTED_ON_DEV_MODE_APPLICATIONS = 93046
46
+ NO_VERSIONS_AVAILABLE_FOR_ACCOUNT = 93054
26
47
  NOT_SUPPORTED_ON_DEV_MODE_APPLICATIONS = 93055
27
48
  APPLICATION_NO_LONGER_AVAILABLE = 93079
49
+ APPLICATION_INSTANCE_FAILED_TO_RUN_SETUP_SCRIPT = 93082
50
+ APPLICATION_INSTANCE_NO_ACTIVE_WAREHOUSE_FOR_CREATE_OR_UPGRADE = 93083
51
+ APPLICATION_INSTANCE_EMPTY_SETUP_SCRIPT = 93084
52
+ VERSION_REFERENCED_BY_RELEASE_DIRECTIVE = 93088
53
+ APPLICATION_PACKAGE_MANIFEST_CONTAINER_IMAGE_URL_BAD_VALUE = 93148
54
+ CANNOT_GRANT_NON_MANIFEST_PRIVILEGE = 93118
28
55
  APPLICATION_OWNS_EXTERNAL_OBJECTS = 93128
56
+ APPLICATION_PACKAGE_PATCH_ALREADY_EXISTS = 93168
57
+ APPLICATION_PACKAGE_CANNOT_SET_EXTERNAL_DISTRIBUTION_WITH_SPCS = 93197
58
+ NATIVE_APPLICATION_MANIFEST_UNRECOGNIZED_FIELD = 93301
59
+ NATIVE_APPLICATION_MANIFEST_UNEXPECTED_VALUE_FOR_PROPERTY = 93302
60
+ NATIVE_APPLICATION_MANIFEST_GENERIC_JSON_ERROR = 93303
61
+ NATIVE_APPLICATION_MANIFEST_INVALID_SYNTAX = 93300
29
62
  APPLICATION_REQUIRES_TELEMETRY_SHARING = 93321
30
63
  CANNOT_DISABLE_MANDATORY_TELEMETRY = 93329
64
+ VERSION_NOT_ADDED_TO_RELEASE_CHANNEL = 512008
65
+ CANNOT_DISABLE_RELEASE_CHANNELS = 512001
66
+ RELEASE_DIRECTIVES_VERSION_PATCH_NOT_FOUND = 93036
67
+ RELEASE_DIRECTIVE_DOES_NOT_EXIST = 93090
68
+ VERSION_DOES_NOT_EXIST = 93031
69
+ VERSION_NOT_IN_RELEASE_CHANNEL = 512010
70
+ ACCOUNT_DOES_NOT_EXIST = 1999
71
+ ACCOUNT_HAS_TOO_MANY_QUALIFIERS = 906
72
+ CANNOT_MODIFY_RELEASE_CHANNEL_ACCOUNTS = 512017
73
+ VERSION_ALREADY_ADDED_TO_RELEASE_CHANNEL = 512005
74
+ MAX_VERSIONS_IN_RELEASE_CHANNEL_REACHED = 512004
75
+ MAX_UNBOUND_VERSIONS_REACHED = 512023
76
+ CANNOT_DEREGISTER_VERSION_ASSOCIATED_WITH_CHANNEL = 512021
77
+ TARGET_ACCOUNT_USED_BY_OTHER_RELEASE_DIRECTIVE = 93091
78
+
79
+
80
+ ERR_JAVASCRIPT_EXECUTION = 100132
81
+
82
+ SNOWSERVICES_IMAGE_REPOSITORY_IMAGE_IMPORT_TO_NATIVE_APP_FAIL = 397007
83
+ SNOWSERVICES_IMAGE_MANIFEST_NOT_FOUND = 397012
84
+ SNOWSERVICES_IMAGE_REPOSITORY_FAILS_TO_RETRIEVE_IMAGE_HASH_NEW = 397013
85
+
86
+ NO_REFERENCE_SET_FOR_DEFINITION = 505019
87
+ NO_ACTIVE_REF_DEFINITION_WITH_REF_NAME_IN_APPLICATION = 505026
@@ -24,20 +24,31 @@ from snowflake.cli.api.config import (
24
24
 
25
25
  class BooleanFlag(NamedTuple):
26
26
  name: str
27
- default: bool = False
27
+ default: bool | None = False
28
28
 
29
29
 
30
30
  @unique
31
31
  class FeatureFlagMixin(Enum):
32
- def is_enabled(self) -> bool:
32
+ def get_value(self) -> bool | None:
33
33
  return get_config_bool_value(
34
34
  *FEATURE_FLAGS_SECTION_PATH,
35
35
  key=self.value.name.lower(),
36
36
  default=self.value.default,
37
37
  )
38
38
 
39
- def is_disabled(self):
40
- return not self.is_enabled()
39
+ def is_enabled(self) -> bool:
40
+ return self.get_value() is True
41
+
42
+ def is_disabled(self) -> bool:
43
+ return self.get_value() is False
44
+
45
+ def is_set(self) -> bool:
46
+ return (
47
+ get_config_bool_value(
48
+ *FEATURE_FLAGS_SECTION_PATH, key=self.value.name.lower(), default=None
49
+ )
50
+ is not None
51
+ )
41
52
 
42
53
  def env_variable(self):
43
54
  return get_env_variable_name(*FEATURE_FLAGS_SECTION_PATH, key=self.value.name)
@@ -52,3 +63,8 @@ class FeatureFlag(FeatureFlagMixin):
52
63
  ENABLE_STREAMLIT_VERSIONED_STAGE = BooleanFlag(
53
64
  "ENABLE_STREAMLIT_VERSIONED_STAGE", False
54
65
  )
66
+ ENABLE_SEPARATE_AUTHENTICATION_POLICY_ID = BooleanFlag(
67
+ "ENABLE_SEPARATE_AUTHENTICATION_POLICY_ID", False
68
+ )
69
+ ENABLE_SPCS_SERVICE_EVENTS = BooleanFlag("ENABLE_SPCS_SERVICE_EVENTS", False)
70
+ ENABLE_SPCS_SERVICE_METRICS = BooleanFlag("ENABLE_SPCS_SERVICE_METRICS", False)
@@ -116,10 +116,8 @@ class CLIMetricsSpan:
116
116
  children: Set[CLIMetricsSpan] = field(init=False, default_factory=set)
117
117
 
118
118
  # private state
119
- # start time of the step from the monotonic clock in order to calculate execution time
120
- _monotonic_start: float = field(
121
- init=False, default_factory=lambda: time.monotonic()
122
- )
119
+ # start time of the step from a performance counter in order to calculate execution time
120
+ _start_time: float = field(init=False, default_factory=time.perf_counter)
123
121
 
124
122
  def __hash__(self) -> int:
125
123
  return hash(self.span_id)
@@ -154,7 +152,7 @@ class CLIMetricsSpan:
154
152
  if error:
155
153
  self.error = error
156
154
 
157
- self.execution_time = time.monotonic() - self._monotonic_start
155
+ self.execution_time = time.perf_counter() - self._start_time
158
156
 
159
157
  def to_dict(self) -> Dict:
160
158
  """
@@ -191,9 +189,10 @@ class CLIMetrics:
191
189
  _in_progress_spans: List[CLIMetricsSpan] = field(init=False, default_factory=list)
192
190
  # list of finished steps for telemetry to process
193
191
  _completed_spans: List[CLIMetricsSpan] = field(init=False, default_factory=list)
194
- # monotonic clock time of when this class was initialized to approximate when the command first started executing
195
- _monotonic_start: float = field(
196
- init=False, default_factory=lambda: time.monotonic(), compare=False
192
+ # clock time of a performance counter when this class was initialized
193
+ # to approximate when the command first started executing
194
+ _start_time: float = field(
195
+ init=False, default_factory=time.perf_counter, compare=False
197
196
  )
198
197
 
199
198
  def clone(self) -> CLIMetrics:
@@ -223,7 +222,7 @@ class CLIMetrics:
223
222
  return self._in_progress_spans[-1] if len(self._in_progress_spans) > 0 else None
224
223
 
225
224
  @contextmanager
226
- def start_span(self, name: str) -> Iterator[CLIMetricsSpan]:
225
+ def span(self, name: str) -> Iterator[CLIMetricsSpan]:
227
226
  """
228
227
  Starts a new span that tracks various metrics throughout its execution
229
228
 
@@ -236,7 +235,7 @@ class CLIMetrics:
236
235
  """
237
236
  new_span = CLIMetricsSpan(
238
237
  name=name,
239
- start_time=time.monotonic() - self._monotonic_start,
238
+ start_time=time.perf_counter() - self._start_time,
240
239
  parent=self.current_span,
241
240
  )
242
241
 
@@ -275,34 +274,29 @@ class CLIMetrics:
275
274
  @property
276
275
  def completed_spans(self) -> List[Dict]:
277
276
  """
278
- Returns the completed spans tracked throughout a command, sorted by start time, for reporting telemetry
277
+ Returns the completed spans tracked throughout a command for reporting telemetry
279
278
 
280
279
  Ensures that the spans we send are within the configured limits and marks
281
280
  certain spans as trimmed if their children would bypass the limits we set
282
281
  """
283
282
  # take spans breadth-first within the depth and total limits
284
283
  # since we care more about the big picture than granularity
285
- spans_to_report = set(
286
- nsmallest(
287
- n=self.SPAN_TOTAL_LIMIT,
288
- iterable=(
289
- span
290
- for span in self._completed_spans
291
- if span.span_depth <= self.SPAN_DEPTH_LIMIT
292
- ),
293
- key=lambda span: (span.span_depth, span.start_time),
294
- )
284
+ spans_to_report = nsmallest(
285
+ n=self.SPAN_TOTAL_LIMIT,
286
+ iterable=(
287
+ span
288
+ for span in self._completed_spans
289
+ if span.span_depth <= self.SPAN_DEPTH_LIMIT
290
+ ),
291
+ key=lambda span: (span.span_depth, span.start_time, span.execution_time),
295
292
  )
296
293
 
297
- # sort by start time to make reading the payload easier
298
- sorted_spans_to_report = sorted(
299
- spans_to_report, key=lambda span: span.start_time
300
- )
294
+ spans_to_report_set = set(spans_to_report)
301
295
 
302
296
  return [
303
297
  {
304
298
  **span.to_dict(),
305
- CLIMetricsSpan.TRIMMED_KEY: not span.children <= spans_to_report,
299
+ CLIMetricsSpan.TRIMMED_KEY: not span.children <= spans_to_report_set,
306
300
  }
307
- for span in sorted_spans_to_report
301
+ for span in spans_to_report
308
302
  ]
@@ -13,7 +13,6 @@ from snowflake.cli._plugins.nativeapp.artifacts import (
13
13
  bundle_artifacts,
14
14
  )
15
15
  from snowflake.cli._plugins.nativeapp.bundle_context import BundleContext
16
- from snowflake.cli._plugins.nativeapp.codegen.compiler import TEMPLATES_PROCESSOR
17
16
  from snowflake.cli._plugins.nativeapp.codegen.templates.templates_processor import (
18
17
  TemplatesProcessor,
19
18
  )
@@ -457,7 +456,7 @@ def _convert_templates_in_files(
457
456
  artifact
458
457
  for artifact in pkg_model.artifacts
459
458
  for processor in artifact.processors
460
- if processor.name == TEMPLATES_PROCESSOR
459
+ if processor.name.lower() == TemplatesProcessor.NAME
461
460
  ]
462
461
  if not in_memory and artifacts_to_template:
463
462
  metrics.set_counter(CLICounterField.TEMPLATES_PROCESSOR, 1)
@@ -15,12 +15,17 @@
15
15
  from __future__ import annotations
16
16
 
17
17
  from dataclasses import dataclass
18
- from typing import Any, Dict, List, Optional, Union
18
+ from types import UnionType
19
+ from typing import Any, Dict, List, Optional, Union, get_args, get_origin
19
20
 
20
21
  from packaging.version import Version
21
22
  from pydantic import Field, ValidationError, field_validator, model_validator
22
23
  from pydantic_core.core_schema import ValidationInfo
23
24
  from snowflake.cli._plugins.nativeapp.entities.application import ApplicationEntityModel
25
+ from snowflake.cli._plugins.nativeapp.entities.application_package import (
26
+ ApplicationPackageChildrenTypes,
27
+ ApplicationPackageEntityModel,
28
+ )
24
29
  from snowflake.cli.api.project.errors import SchemaValidationError
25
30
  from snowflake.cli.api.project.schemas.entities.common import (
26
31
  TargetField,
@@ -159,6 +164,12 @@ class DefinitionV20(_ProjectDefinitionBase):
159
164
  target_object = entity.from_
160
165
  target_type = target_object.get_type()
161
166
  cls._validate_target_field(target_key, target_type, entities)
167
+ elif entity.type == ApplicationPackageEntityModel.get_type():
168
+ for child_entity in entity.children:
169
+ target_key = child_entity.target
170
+ cls._validate_target_field(
171
+ target_key, ApplicationPackageChildrenTypes, entities
172
+ )
162
173
 
163
174
  @classmethod
164
175
  def _validate_target_field(
@@ -168,11 +179,20 @@ class DefinitionV20(_ProjectDefinitionBase):
168
179
  raise ValueError(f"No such target: {target_key}")
169
180
 
170
181
  # Validate the target type
171
- actual_target_type = entities[target_key].__class__
172
- if target_type and target_type is not actual_target_type:
173
- raise ValueError(
174
- f"Target type mismatch. Expected {target_type.__name__}, got {actual_target_type.__name__}"
175
- )
182
+ if target_type:
183
+ actual_target_type = entities[target_key].__class__
184
+ if get_origin(target_type) in (Union, UnionType):
185
+ if actual_target_type not in get_args(target_type):
186
+ expected_types_str = ", ".join(
187
+ [t.__name__ for t in get_args(target_type)]
188
+ )
189
+ raise ValueError(
190
+ f"Target type mismatch. Expected one of [{expected_types_str}], got {actual_target_type.__name__}"
191
+ )
192
+ elif target_type is not actual_target_type:
193
+ raise ValueError(
194
+ f"Target type mismatch. Expected {target_type.__name__}, got {actual_target_type.__name__}"
195
+ )
176
196
 
177
197
  @model_validator(mode="before")
178
198
  @classmethod
@@ -200,6 +220,7 @@ class DefinitionV20(_ProjectDefinitionBase):
200
220
  mixin_defs=data["mixins"],
201
221
  )
202
222
  entities[entity_name] = merged_values
223
+
203
224
  return data
204
225
 
205
226
  @classmethod
@@ -27,7 +27,7 @@ class Streamlit(UpdatableModel, ObjectIdentifierModel(object_name="Streamlit")):
27
27
  title="Stage in which the app’s artifacts will be stored", default="streamlit"
28
28
  )
29
29
  query_warehouse: str = Field(
30
- title="Snowflake warehouse to host the app", default="streamlit"
30
+ title="Snowflake warehouse to host the app", default=None
31
31
  )
32
32
  main_file: Optional[Path] = Field(
33
33
  title="Entrypoint file of the Streamlit app", default="streamlit_app.py"
@@ -148,6 +148,10 @@ def unquote_identifier(identifier: str) -> str:
148
148
  string for a LIKE clause, or to match an identifier passed back as
149
149
  a value from a SQL statement.
150
150
  """
151
+ # ensure input is a valid identifier - otherwise, it could accidentally uppercase
152
+ # a quoted identifier
153
+ identifier = to_identifier(identifier)
154
+
151
155
  if match := re.fullmatch(QUOTED_IDENTIFIER_REGEX, identifier):
152
156
  return match.group(1).replace('""', '"')
153
157
  # unquoted identifiers are internally represented as uppercase
@@ -276,3 +280,44 @@ def append_test_resource_suffix(identifier: str) -> str:
276
280
  # Otherwise just append the string, don't add quotes
277
281
  # in case the user doesn't want them
278
282
  return f"{identifier}{suffix}"
283
+
284
+
285
+ def same_identifiers(id1: str, id2: str) -> bool:
286
+ """
287
+ Returns whether two identifiers refer to the same object.
288
+
289
+ Two unquoted identifiers are considered the same if they are equal when both are converted to uppercase
290
+ Two quoted identifiers are considered the same if they are exactly equal
291
+ An unquoted identifier and a quoted identifier are considered the same
292
+ if the quoted identifier is equal to the uppercase version of the unquoted identifier
293
+ """
294
+ # Canonicalize the identifiers by converting unquoted identifiers to uppercase and leaving quoted identifiers as is
295
+ canonical_id1 = id1.upper() if is_valid_unquoted_identifier(id1) else id1
296
+ canonical_id2 = id2.upper() if is_valid_unquoted_identifier(id2) else id2
297
+
298
+ # The canonical identifiers are equal if they are equal when both are quoted
299
+ # (if they are already quoted, this is a no-op)
300
+ return to_quoted_identifier(canonical_id1) == to_quoted_identifier(canonical_id2)
301
+
302
+
303
+ def sql_match(*, pattern: str, value: str) -> bool:
304
+ """
305
+ Returns whether the value matches the pattern when used with LIKE in Snowflake.
306
+ Compares the 2 input and ignores the case.
307
+ """
308
+ value = unquote_identifier(value)
309
+ return (
310
+ re.fullmatch(
311
+ pattern.replace(r"%", ".*").replace(r"_", "."), value, re.IGNORECASE
312
+ )
313
+ is not None
314
+ )
315
+
316
+
317
+ def identifier_in_list(identifier: str, identifier_list: list[str]) -> bool:
318
+ """
319
+ Returns whether the identifier is in the list of identifiers.
320
+ """
321
+ return any(
322
+ same_identifiers(identifier, id_from_list) for id_from_list in identifier_list
323
+ )