snowflake-cli 3.2.2__py3-none-any.whl → 3.4.1__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.
- snowflake/cli/__about__.py +1 -1
- snowflake/cli/_app/__main__.py +2 -2
- snowflake/cli/_app/cli_app.py +224 -192
- snowflake/cli/_app/commands_registration/commands_registration_with_callbacks.py +1 -27
- snowflake/cli/_app/constants.py +4 -0
- snowflake/cli/_app/snow_connector.py +12 -0
- snowflake/cli/_app/telemetry.py +10 -3
- snowflake/cli/_plugins/connection/util.py +12 -19
- snowflake/cli/_plugins/cortex/commands.py +2 -4
- snowflake/cli/_plugins/git/manager.py +1 -1
- snowflake/cli/_plugins/helpers/commands.py +207 -1
- snowflake/cli/_plugins/nativeapp/artifacts.py +16 -628
- snowflake/cli/_plugins/nativeapp/bundle_context.py +1 -1
- snowflake/cli/_plugins/nativeapp/codegen/artifact_processor.py +1 -1
- snowflake/cli/_plugins/nativeapp/codegen/compiler.py +42 -20
- snowflake/cli/_plugins/nativeapp/codegen/setup/native_app_setup_processor.py +9 -2
- snowflake/cli/_plugins/nativeapp/codegen/snowpark/python_processor.py +6 -3
- snowflake/cli/_plugins/nativeapp/codegen/templates/templates_processor.py +44 -34
- snowflake/cli/_plugins/nativeapp/commands.py +113 -21
- snowflake/cli/_plugins/nativeapp/constants.py +5 -0
- snowflake/cli/_plugins/nativeapp/entities/application.py +226 -296
- snowflake/cli/_plugins/nativeapp/entities/application_package.py +911 -141
- snowflake/cli/_plugins/nativeapp/entities/application_package_child_interface.py +43 -0
- snowflake/cli/_plugins/nativeapp/feature_flags.py +5 -1
- snowflake/cli/_plugins/nativeapp/release_channel/__init__.py +13 -0
- snowflake/cli/_plugins/nativeapp/release_channel/commands.py +246 -0
- snowflake/cli/_plugins/nativeapp/release_directive/__init__.py +13 -0
- snowflake/cli/_plugins/nativeapp/release_directive/commands.py +243 -0
- snowflake/cli/_plugins/nativeapp/same_account_install_method.py +9 -17
- snowflake/cli/_plugins/nativeapp/sf_facade_exceptions.py +80 -0
- snowflake/cli/_plugins/nativeapp/sf_sql_facade.py +1184 -80
- snowflake/cli/_plugins/nativeapp/utils.py +11 -0
- snowflake/cli/_plugins/nativeapp/v2_conversions/compat.py +7 -3
- snowflake/cli/_plugins/nativeapp/version/commands.py +32 -5
- snowflake/cli/_plugins/notebook/commands.py +55 -2
- snowflake/cli/_plugins/notebook/exceptions.py +1 -1
- snowflake/cli/_plugins/notebook/manager.py +7 -5
- snowflake/cli/_plugins/notebook/notebook_entity.py +120 -0
- snowflake/cli/_plugins/notebook/notebook_entity_model.py +42 -0
- snowflake/cli/_plugins/notebook/notebook_project_paths.py +15 -0
- snowflake/cli/_plugins/notebook/types.py +3 -0
- snowflake/cli/_plugins/snowpark/commands.py +48 -30
- snowflake/cli/_plugins/snowpark/common.py +47 -2
- snowflake/cli/_plugins/snowpark/snowpark_entity.py +247 -4
- snowflake/cli/_plugins/snowpark/snowpark_entity_model.py +18 -30
- snowflake/cli/_plugins/snowpark/snowpark_project_paths.py +156 -23
- snowflake/cli/_plugins/snowpark/zipper.py +33 -1
- snowflake/cli/_plugins/spcs/common.py +129 -0
- snowflake/cli/_plugins/spcs/services/commands.py +131 -14
- snowflake/cli/_plugins/spcs/services/manager.py +169 -1
- snowflake/cli/_plugins/stage/commands.py +2 -1
- snowflake/cli/_plugins/stage/diff.py +60 -39
- snowflake/cli/_plugins/stage/manager.py +34 -13
- snowflake/cli/_plugins/stage/utils.py +1 -1
- snowflake/cli/_plugins/streamlit/commands.py +10 -1
- snowflake/cli/_plugins/streamlit/manager.py +70 -22
- snowflake/cli/_plugins/streamlit/streamlit_entity.py +131 -1
- snowflake/cli/_plugins/streamlit/streamlit_entity_model.py +14 -24
- snowflake/cli/_plugins/streamlit/streamlit_project_paths.py +30 -0
- snowflake/cli/_plugins/workspace/commands.py +6 -5
- snowflake/cli/_plugins/workspace/manager.py +9 -5
- snowflake/cli/api/artifacts/__init__.py +13 -0
- snowflake/cli/api/artifacts/bundle_map.py +500 -0
- snowflake/cli/api/artifacts/common.py +78 -0
- snowflake/cli/api/artifacts/utils.py +82 -0
- snowflake/cli/api/cli_global_context.py +36 -2
- snowflake/cli/api/commands/flags.py +10 -4
- snowflake/cli/api/commands/utils.py +28 -2
- snowflake/cli/api/config.py +6 -2
- snowflake/cli/api/connections.py +12 -1
- snowflake/cli/api/constants.py +10 -1
- snowflake/cli/api/entities/common.py +81 -14
- snowflake/cli/api/entities/resolver.py +160 -0
- snowflake/cli/api/entities/utils.py +65 -23
- snowflake/cli/api/errno.py +63 -3
- snowflake/cli/api/feature_flags.py +19 -4
- snowflake/cli/api/metrics.py +21 -27
- snowflake/cli/api/project/definition_conversion.py +4 -4
- snowflake/cli/api/project/project_paths.py +28 -0
- snowflake/cli/api/project/schemas/entities/common.py +130 -1
- snowflake/cli/api/project/schemas/entities/entities.py +4 -0
- snowflake/cli/api/project/schemas/project_definition.py +54 -6
- snowflake/cli/api/project/schemas/updatable_model.py +2 -2
- snowflake/cli/api/project/schemas/v1/native_app/native_app.py +5 -7
- snowflake/cli/api/project/schemas/v1/streamlit/streamlit.py +1 -1
- snowflake/cli/api/project/util.py +45 -0
- snowflake/cli/api/secure_path.py +6 -0
- snowflake/cli/api/sql_execution.py +5 -1
- snowflake/cli/api/stage_path.py +7 -2
- snowflake/cli/api/utils/graph.py +3 -0
- snowflake/cli/api/utils/path_utils.py +24 -0
- {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.4.1.dist-info}/METADATA +14 -15
- {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.4.1.dist-info}/RECORD +96 -82
- {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.4.1.dist-info}/WHEEL +1 -1
- snowflake/cli/api/project/schemas/v1/native_app/path_mapping.py +0 -65
- {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.4.1.dist-info}/entry_points.txt +0 -0
- {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.4.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -17,8 +17,9 @@ 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
|
-
from typing import TYPE_CHECKING, Iterator
|
|
22
|
+
from typing import TYPE_CHECKING, Iterator, Optional
|
|
22
23
|
|
|
23
24
|
from snowflake.cli.api.connections import ConnectionContext, OpenConnectionCache
|
|
24
25
|
from snowflake.cli.api.exceptions import MissingConfiguration
|
|
@@ -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
|
|
@@ -195,6 +196,19 @@ class _CliGlobalContextAccess:
|
|
|
195
196
|
"""Computes whether cli_console output should be muted."""
|
|
196
197
|
return self._manager.output_format == OutputFormat.JSON
|
|
197
198
|
|
|
199
|
+
@property
|
|
200
|
+
def snow_api_root(
|
|
201
|
+
self,
|
|
202
|
+
) -> Optional[
|
|
203
|
+
object
|
|
204
|
+
]: # Should be Optional[Root], but we need local import for performance reasons
|
|
205
|
+
from snowflake.core import Root
|
|
206
|
+
|
|
207
|
+
if self.connection:
|
|
208
|
+
return Root(self.connection)
|
|
209
|
+
else:
|
|
210
|
+
return None
|
|
211
|
+
|
|
198
212
|
|
|
199
213
|
_CLI_CONTEXT_MANAGER: ContextVar[_CliGlobalContextManager | None] = ContextVar(
|
|
200
214
|
"cli_context", default=None
|
|
@@ -213,6 +227,26 @@ def get_cli_context() -> _CliGlobalContextAccess:
|
|
|
213
227
|
return _CliGlobalContextAccess(get_cli_context_manager())
|
|
214
228
|
|
|
215
229
|
|
|
230
|
+
def span(span_name: str):
|
|
231
|
+
"""
|
|
232
|
+
Decorator to start a command metrics span that encompasses a whole function
|
|
233
|
+
|
|
234
|
+
Must be used instead of directly calling @get_cli_context().metrics.span(span_name)
|
|
235
|
+
as a decorator to ensure that the cli context is grabbed at run time instead of at
|
|
236
|
+
module load time, which would not reflect forking
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
def decorator(func):
|
|
240
|
+
@wraps(func)
|
|
241
|
+
def wrapper(*args, **kwargs):
|
|
242
|
+
with get_cli_context().metrics.span(span_name):
|
|
243
|
+
return func(*args, **kwargs)
|
|
244
|
+
|
|
245
|
+
return wrapper
|
|
246
|
+
|
|
247
|
+
return decorator
|
|
248
|
+
|
|
249
|
+
|
|
216
250
|
@contextmanager
|
|
217
251
|
def fork_cli_context(
|
|
218
252
|
connection_overrides: dict | None = None,
|
|
@@ -95,7 +95,7 @@ TemporaryConnectionOption = typer.Option(
|
|
|
95
95
|
False,
|
|
96
96
|
"--temporary-connection",
|
|
97
97
|
"-x",
|
|
98
|
-
help="Uses connection defined with command line parameters, instead of one defined in config",
|
|
98
|
+
help="Uses a connection defined with command line parameters, instead of one defined in config",
|
|
99
99
|
callback=_connection_callback("temporary_connection"),
|
|
100
100
|
is_flag=True,
|
|
101
101
|
rich_help_panel=_CONNECTION_SECTION,
|
|
@@ -383,6 +383,12 @@ ReplaceOption = OverrideableOption(
|
|
|
383
383
|
mutually_exclusive=CREATE_MODE_OPTION_NAMES,
|
|
384
384
|
)
|
|
385
385
|
|
|
386
|
+
ForceReplaceOption = OverrideableOption(
|
|
387
|
+
False,
|
|
388
|
+
"--force-replace",
|
|
389
|
+
help="Replace this object, even if the state didn't change",
|
|
390
|
+
)
|
|
391
|
+
|
|
386
392
|
OnErrorOption = typer.Option(
|
|
387
393
|
OnErrorType.BREAK.value,
|
|
388
394
|
"--on-error",
|
|
@@ -531,8 +537,8 @@ def project_definition_option(is_optional: bool):
|
|
|
531
537
|
None,
|
|
532
538
|
"-p",
|
|
533
539
|
"--project",
|
|
534
|
-
help=f"Path where Snowflake project
|
|
535
|
-
f"Defaults to current working directory.",
|
|
540
|
+
help=f"Path where the Snowflake project is stored. "
|
|
541
|
+
f"Defaults to the current working directory.",
|
|
536
542
|
callback=project_path_callback,
|
|
537
543
|
show_default=False,
|
|
538
544
|
)
|
|
@@ -551,7 +557,7 @@ def project_env_overrides_option():
|
|
|
551
557
|
return typer.Option(
|
|
552
558
|
[],
|
|
553
559
|
"--env",
|
|
554
|
-
help="String in format
|
|
560
|
+
help="String in the format key=value. Overrides variables from the env section used for templates.",
|
|
555
561
|
callback=project_env_overrides_callback,
|
|
556
562
|
show_default=False,
|
|
557
563
|
)
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
from typing import List, Optional
|
|
1
|
+
from typing import Dict, List, Optional
|
|
2
2
|
|
|
3
|
-
from click import ClickException
|
|
3
|
+
from click import ClickException, UsageError
|
|
4
4
|
from snowflake.cli.api.commands.common import Variable
|
|
5
|
+
from snowflake.cli.api.exceptions import NoProjectDefinitionError
|
|
6
|
+
from snowflake.cli.api.project.schemas.entities.common import EntityModelBase
|
|
5
7
|
|
|
6
8
|
|
|
7
9
|
def parse_key_value_variables(variables: Optional[List[str]]) -> List[Variable]:
|
|
@@ -16,3 +18,27 @@ def parse_key_value_variables(variables: Optional[List[str]]) -> List[Variable]:
|
|
|
16
18
|
key, value = p.split("=", 1)
|
|
17
19
|
result.append(Variable(key.strip(), value.strip()))
|
|
18
20
|
return result
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_entity_for_operation(
|
|
24
|
+
cli_context,
|
|
25
|
+
entity_id: str | None,
|
|
26
|
+
project_definition,
|
|
27
|
+
entity_type: str,
|
|
28
|
+
):
|
|
29
|
+
entities: Dict[str, EntityModelBase] = project_definition.get_entities_by_type(
|
|
30
|
+
entity_type=entity_type
|
|
31
|
+
)
|
|
32
|
+
if not entities:
|
|
33
|
+
raise NoProjectDefinitionError(
|
|
34
|
+
project_type=entity_type, project_root=cli_context.project_root
|
|
35
|
+
)
|
|
36
|
+
if entity_id and entity_id not in entities:
|
|
37
|
+
raise UsageError(f"No '{entity_id}' entity in project definition file.")
|
|
38
|
+
if len(entities.keys()) == 1:
|
|
39
|
+
entity_id = list(entities.keys())[0]
|
|
40
|
+
if entity_id is None:
|
|
41
|
+
raise UsageError(
|
|
42
|
+
f"Multiple entities of type {entity_type} found. Please provide entity id for the operation."
|
|
43
|
+
)
|
|
44
|
+
return entities[entity_id]
|
snowflake/cli/api/config.py
CHANGED
|
@@ -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[
|
|
290
|
-
value = get_config_value(*path, key=key, 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:
|
snowflake/cli/api/connections.py
CHANGED
|
@@ -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.
|
|
101
|
+
self.merge_with_config(**connection_config)
|
|
91
102
|
return self
|
|
92
103
|
|
|
93
104
|
def __repr__(self) -> str:
|
snowflake/cli/api/constants.py
CHANGED
|
@@ -45,6 +45,7 @@ class ObjectType(Enum):
|
|
|
45
45
|
)
|
|
46
46
|
# JOB = ObjectNames("job", "job", "jobs")
|
|
47
47
|
NETWORK_RULE = ObjectNames("network-rule", "network rule", "network rules")
|
|
48
|
+
NOTEBOOK = ObjectNames("notebook", "notebook", "notebooks")
|
|
48
49
|
PROCEDURE = ObjectNames("procedure", "procedure", "procedures")
|
|
49
50
|
ROLE = ObjectNames("role", "role", "roles")
|
|
50
51
|
SCHEMA = ObjectNames("schema", "schema", "schemas")
|
|
@@ -62,6 +63,10 @@ class ObjectType(Enum):
|
|
|
62
63
|
"image-repository", "image repository", "image repositories"
|
|
63
64
|
)
|
|
64
65
|
GIT_REPOSITORY = ObjectNames("git-repository", "git repository", "git repositories")
|
|
66
|
+
APPLICATION = ObjectNames("application", "application", "applications")
|
|
67
|
+
APPLICATION_PACKAGE = ObjectNames(
|
|
68
|
+
"application-package", "application package", "application packages"
|
|
69
|
+
)
|
|
65
70
|
|
|
66
71
|
def __str__(self):
|
|
67
72
|
"""This makes using this Enum easier in formatted string"""
|
|
@@ -69,7 +74,11 @@ class ObjectType(Enum):
|
|
|
69
74
|
|
|
70
75
|
|
|
71
76
|
OBJECT_TO_NAMES = {o.value.cli_name: o.value for o in ObjectType}
|
|
72
|
-
|
|
77
|
+
UNSUPPORTED_OBJECTS = {
|
|
78
|
+
ObjectType.APPLICATION.value.cli_name,
|
|
79
|
+
ObjectType.APPLICATION_PACKAGE.value.cli_name,
|
|
80
|
+
}
|
|
81
|
+
SUPPORTED_OBJECTS = sorted(OBJECT_TO_NAMES.keys() - UNSUPPORTED_OBJECTS)
|
|
73
82
|
|
|
74
83
|
# Scope names here must replace spaces with '-'. For example 'compute pool' is 'compute-pool'.
|
|
75
84
|
VALID_SCOPES = ["database", "schema", "compute-pool"]
|
|
@@ -1,23 +1,42 @@
|
|
|
1
|
-
|
|
1
|
+
import functools
|
|
2
|
+
from pathlib import Path
|
|
2
3
|
from typing import Generic, Type, TypeVar, get_args
|
|
3
4
|
|
|
4
5
|
from snowflake.cli._plugins.workspace.context import ActionContext, WorkspaceContext
|
|
6
|
+
from snowflake.cli.api.cli_global_context import get_cli_context, span
|
|
7
|
+
from snowflake.cli.api.entities.resolver import DependencyResolver
|
|
8
|
+
from snowflake.cli.api.entities.utils import EntityActions, get_sql_executor
|
|
9
|
+
from snowflake.cli.api.identifiers import FQN
|
|
5
10
|
from snowflake.cli.api.sql_execution import SqlExecutor
|
|
11
|
+
from snowflake.connector import SnowflakeConnection
|
|
12
|
+
from snowflake.connector.cursor import SnowflakeCursor
|
|
6
13
|
|
|
14
|
+
T = TypeVar("T")
|
|
7
15
|
|
|
8
|
-
class EntityActions(str, Enum):
|
|
9
|
-
BUNDLE = "action_bundle"
|
|
10
|
-
DEPLOY = "action_deploy"
|
|
11
|
-
DROP = "action_drop"
|
|
12
|
-
VALIDATE = "action_validate"
|
|
13
|
-
EVENTS = "action_events"
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
def attach_spans_to_entity_actions(entity_name: str):
|
|
18
|
+
"""
|
|
19
|
+
Class decorator for EntityBase subclasses to automatically wrap
|
|
20
|
+
every implemented entity action method with a metrics span
|
|
18
21
|
|
|
22
|
+
Args:
|
|
23
|
+
entity_name (str): Custom name for entity type to be displayed in metrics
|
|
24
|
+
"""
|
|
19
25
|
|
|
20
|
-
T
|
|
26
|
+
def decorator(cls: type[T]) -> type[T]:
|
|
27
|
+
for attr_name, attr_value in vars(cls).items():
|
|
28
|
+
is_entity_action = attr_name in [
|
|
29
|
+
enum_member for enum_member in EntityActions
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
if is_entity_action and callable(attr_value):
|
|
33
|
+
attr_name_without_action_prefix = attr_name.partition("_")[2]
|
|
34
|
+
span_name = f"action.{entity_name}.{attr_name_without_action_prefix}"
|
|
35
|
+
action_with_span = span(span_name)(attr_value)
|
|
36
|
+
setattr(cls, attr_name, action_with_span)
|
|
37
|
+
return cls
|
|
38
|
+
|
|
39
|
+
return decorator
|
|
21
40
|
|
|
22
41
|
|
|
23
42
|
class EntityBase(Generic[T]):
|
|
@@ -28,6 +47,11 @@ class EntityBase(Generic[T]):
|
|
|
28
47
|
def __init__(self, entity_model: T, workspace_ctx: WorkspaceContext):
|
|
29
48
|
self._entity_model = entity_model
|
|
30
49
|
self._workspace_ctx = workspace_ctx
|
|
50
|
+
self.dependency_resolver = DependencyResolver(entity_model)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def entity_id(self) -> str:
|
|
54
|
+
return self._entity_model.entity_id # type: ignore
|
|
31
55
|
|
|
32
56
|
@classmethod
|
|
33
57
|
def get_entity_model_type(cls) -> Type[T]:
|
|
@@ -49,10 +73,53 @@ class EntityBase(Generic[T]):
|
|
|
49
73
|
):
|
|
50
74
|
"""
|
|
51
75
|
Performs the requested action.
|
|
76
|
+
This is a preferred way to perform actions on entities, over calling actions directly,
|
|
77
|
+
as it will also call the dependencies in the correct order.
|
|
52
78
|
"""
|
|
79
|
+
self.dependency_resolver.perform_for_dep(action, action_ctx, *args, **kwargs)
|
|
53
80
|
return getattr(self, action)(action_ctx, *args, **kwargs)
|
|
54
81
|
|
|
82
|
+
@property
|
|
83
|
+
def root(self) -> Path:
|
|
84
|
+
return self._workspace_ctx.project_root
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def identifier(self) -> str:
|
|
88
|
+
return self.model.fqn.sql_identifier
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def fqn(self) -> FQN:
|
|
92
|
+
return self._entity_model.fqn # type: ignore[attr-defined]
|
|
93
|
+
|
|
94
|
+
@functools.cached_property
|
|
95
|
+
def _sql_executor(
|
|
96
|
+
self,
|
|
97
|
+
) -> SqlExecutor:
|
|
98
|
+
return get_sql_executor()
|
|
99
|
+
|
|
100
|
+
def _execute_query(self, sql: str) -> SnowflakeCursor:
|
|
101
|
+
return self._sql_executor.execute_query(sql)
|
|
102
|
+
|
|
103
|
+
@functools.cached_property
|
|
104
|
+
def _conn(self) -> SnowflakeConnection:
|
|
105
|
+
return self._sql_executor._conn # noqa
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def snow_api_root(self):
|
|
109
|
+
return get_cli_context().snow_api_root
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def model(self):
|
|
113
|
+
return self._entity_model
|
|
114
|
+
|
|
115
|
+
def dependent_entities(self, action_ctx: ActionContext):
|
|
116
|
+
return self.dependency_resolver.depends_on(action_ctx)
|
|
117
|
+
|
|
118
|
+
def get_usage_grant_sql(self, app_role: str) -> str:
|
|
119
|
+
return f"GRANT USAGE ON {self.model.type.upper()} {self.identifier} TO ROLE {app_role};"
|
|
120
|
+
|
|
121
|
+
def get_describe_sql(self) -> str:
|
|
122
|
+
return f"DESCRIBE {self.model.type.upper()} {self.identifier};"
|
|
55
123
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
return SqlExecutor()
|
|
124
|
+
def get_drop_sql(self) -> str:
|
|
125
|
+
return f"DROP {self.model.type.upper()} {self.identifier};"
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Dict, List, Tuple
|
|
3
|
+
|
|
4
|
+
from snowflake.cli._plugins.workspace.context import ActionContext
|
|
5
|
+
from snowflake.cli.api.entities.utils import EntityActions
|
|
6
|
+
from snowflake.cli.api.exceptions import CycleDetectedError
|
|
7
|
+
from snowflake.cli.api.project.schemas.entities.common import EntityModelBase
|
|
8
|
+
from snowflake.cli.api.utils.graph import Graph, Node
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Dependency:
|
|
13
|
+
entity_id: str
|
|
14
|
+
call_arguments: Dict[str, Any]
|
|
15
|
+
|
|
16
|
+
def __eq__(self, other):
|
|
17
|
+
return self.entity_id == other.entity_id
|
|
18
|
+
|
|
19
|
+
def __hash__(self):
|
|
20
|
+
return hash(self.entity_id)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DependencyResolver:
|
|
24
|
+
"""
|
|
25
|
+
Base class for resolving dependencies logic.
|
|
26
|
+
Any logic for resolving dependencies, calling their actions or validating them should be implemented here.
|
|
27
|
+
If an entity uses it's specific logic, it should implement its own resolver, inheriting from this one
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, model: EntityModelBase):
|
|
31
|
+
self.entity_model = model
|
|
32
|
+
self.dependencies: List[Dependency] = []
|
|
33
|
+
|
|
34
|
+
def depends_on(self, action_ctx: ActionContext) -> List[Dependency]:
|
|
35
|
+
"""
|
|
36
|
+
Returns a list of entities that this entity depends on.
|
|
37
|
+
The list is sorted in order they should be called- last one depends on all the previous.
|
|
38
|
+
"""
|
|
39
|
+
if not self.dependencies:
|
|
40
|
+
graph = self._create_dependency_graph(action_ctx)
|
|
41
|
+
self.dependencies = self._check_and_sort_dependencies(graph)
|
|
42
|
+
|
|
43
|
+
return self.dependencies
|
|
44
|
+
|
|
45
|
+
def perform_for_dep(
|
|
46
|
+
self, action: EntityActions, action_ctx: ActionContext, *args, **kwargs
|
|
47
|
+
):
|
|
48
|
+
"""
|
|
49
|
+
Method used to perform selected
|
|
50
|
+
"""
|
|
51
|
+
for dependency in self.depends_on(action_ctx):
|
|
52
|
+
entity = action_ctx.get_entity(dependency.entity_id)
|
|
53
|
+
if entity.supports(action):
|
|
54
|
+
arguments = dependency.call_arguments.get(action.get_action_name, {})
|
|
55
|
+
getattr(entity, action)(action_ctx, **arguments)
|
|
56
|
+
|
|
57
|
+
def _create_dependency_graph(self, action_ctx: ActionContext) -> Graph[Dependency]:
|
|
58
|
+
"""
|
|
59
|
+
Creates a graph for dependencies. We need the graph, instead of a simple list, because we need to check if
|
|
60
|
+
calling dependencies actions in selected order is possible.
|
|
61
|
+
"""
|
|
62
|
+
graph = Graph()
|
|
63
|
+
depends_on = self.entity_model.meta.depends_on if self.entity_model.meta else [] # type: ignore
|
|
64
|
+
self_dependency = Dependency(entity_id=self.entity_model.entity_id, call_arguments={}) # type: ignore
|
|
65
|
+
resolved_nodes = set()
|
|
66
|
+
|
|
67
|
+
graph.add(Node(key=self_dependency.entity_id, data=self_dependency))
|
|
68
|
+
|
|
69
|
+
def _resolve_dependencies(parent_id: str, dependency_id: str) -> None:
|
|
70
|
+
|
|
71
|
+
(
|
|
72
|
+
child_dependencies,
|
|
73
|
+
call_arguments,
|
|
74
|
+
) = self._get_child_dependencies_and_call_arguments(
|
|
75
|
+
dependency_id=dependency_id, action_ctx=action_ctx
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if not graph.contains_node(dependency_id):
|
|
79
|
+
dependency_node = Node(
|
|
80
|
+
key=dependency_id,
|
|
81
|
+
data=Dependency(
|
|
82
|
+
entity_id=dependency_id, call_arguments=call_arguments
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
graph.add(dependency_node)
|
|
86
|
+
|
|
87
|
+
graph.add_directed_edge(parent_id, dependency_id)
|
|
88
|
+
|
|
89
|
+
resolved_nodes.add(dependency_node.key)
|
|
90
|
+
|
|
91
|
+
for child_dependency in child_dependencies:
|
|
92
|
+
if child_dependency not in resolved_nodes:
|
|
93
|
+
_resolve_dependencies(dependency_node.key, child_dependency)
|
|
94
|
+
else:
|
|
95
|
+
graph.add_directed_edge(dependency_node.key, child_dependency)
|
|
96
|
+
|
|
97
|
+
for dependency in depends_on:
|
|
98
|
+
_resolve_dependencies(self_dependency.entity_id, dependency)
|
|
99
|
+
|
|
100
|
+
return graph
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _check_and_sort_dependencies(
|
|
104
|
+
graph: Graph[Dependency],
|
|
105
|
+
) -> List[Dependency]:
|
|
106
|
+
"""
|
|
107
|
+
This function is used to check and organize the dependency list.
|
|
108
|
+
The check has two stages:
|
|
109
|
+
* Cycle detection in dependency
|
|
110
|
+
* Clearing duplicate
|
|
111
|
+
|
|
112
|
+
In the first stage, if cycle is detected, it raises CycleDetectedError with node causing it specified.
|
|
113
|
+
The result list, shows entities this one depends on, in order they should be called.
|
|
114
|
+
Duplicates are removed in a way, that preserves earliest possible call.
|
|
115
|
+
Last item is removed from the result list, as it is this entity itself.
|
|
116
|
+
"""
|
|
117
|
+
result = []
|
|
118
|
+
|
|
119
|
+
def _on_cycle(node: Node[Dependency]) -> None:
|
|
120
|
+
raise CycleDetectedError(
|
|
121
|
+
f"Cycle detected in entity dependencies: {node.key}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _on_visit(node: Node[Dependency]) -> None:
|
|
125
|
+
result.append(node.data)
|
|
126
|
+
|
|
127
|
+
graph.dfs(on_cycle_action=_on_cycle, visit_action=_on_visit)
|
|
128
|
+
|
|
129
|
+
return clear_duplicates_from_list(result)[:-1]
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _get_child_dependencies_and_call_arguments(
|
|
133
|
+
dependency_id: str, action_ctx: ActionContext
|
|
134
|
+
) -> Tuple[List[str], Dict[str, Any]]:
|
|
135
|
+
child_dependency = action_ctx.get_entity(dependency_id)
|
|
136
|
+
|
|
137
|
+
if not child_dependency:
|
|
138
|
+
raise ValueError(f"Entity with id {dependency_id} not found in project")
|
|
139
|
+
|
|
140
|
+
if child_dependency.model.meta:
|
|
141
|
+
return (
|
|
142
|
+
child_dependency.model.meta.depends_on,
|
|
143
|
+
child_dependency.model.meta.action_arguments,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
else:
|
|
147
|
+
return [], {}
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def get_action_name(action: EntityActions) -> str:
|
|
151
|
+
|
|
152
|
+
return action.value.split("_")[1]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def clear_duplicates_from_list(input_list: list[Any]) -> list[Any]:
|
|
156
|
+
"""
|
|
157
|
+
Removes duplicates from the input list, preserving the first occurrence.
|
|
158
|
+
"""
|
|
159
|
+
seen = set()
|
|
160
|
+
return [x for x in input_list if not (x in seen or seen.add(x))] # type: ignore
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import os
|
|
2
|
+
from enum import Enum
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
from typing import Any, List, NoReturn, Optional
|
|
4
5
|
|
|
5
6
|
import jinja2
|
|
6
7
|
from click import ClickException
|
|
7
|
-
from snowflake.cli._plugins.nativeapp.artifacts import (
|
|
8
|
-
BundleMap,
|
|
9
|
-
resolve_without_follow,
|
|
10
|
-
)
|
|
11
8
|
from snowflake.cli._plugins.nativeapp.exceptions import (
|
|
12
9
|
InvalidTemplateInFileError,
|
|
13
10
|
MissingScriptError,
|
|
@@ -22,10 +19,11 @@ from snowflake.cli._plugins.stage.diff import (
|
|
|
22
19
|
sync_local_diff_with_stage,
|
|
23
20
|
to_stage_path,
|
|
24
21
|
)
|
|
22
|
+
from snowflake.cli._plugins.stage.manager import DefaultStagePathParts
|
|
25
23
|
from snowflake.cli._plugins.stage.utils import print_diff_to_console
|
|
26
|
-
from snowflake.cli.api.
|
|
24
|
+
from snowflake.cli.api.artifacts.bundle_map import BundleMap
|
|
25
|
+
from snowflake.cli.api.cli_global_context import get_cli_context, span
|
|
27
26
|
from snowflake.cli.api.console.abc import AbstractConsole
|
|
28
|
-
from snowflake.cli.api.entities.common import get_sql_executor
|
|
29
27
|
from snowflake.cli.api.errno import (
|
|
30
28
|
DOES_NOT_EXIST_OR_CANNOT_BE_PERFORMED,
|
|
31
29
|
NO_WAREHOUSE_SELECTED_IN_SESSION,
|
|
@@ -41,8 +39,9 @@ from snowflake.cli.api.rendering.sql_templates import (
|
|
|
41
39
|
choose_sql_jinja_env_based_on_template_syntax,
|
|
42
40
|
)
|
|
43
41
|
from snowflake.cli.api.secure_path import UNLIMITED, SecurePath
|
|
42
|
+
from snowflake.cli.api.sql_execution import SqlExecutor
|
|
43
|
+
from snowflake.cli.api.utils.path_utils import resolve_without_follow
|
|
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,16 +75,16 @@ 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,
|
|
82
82
|
package_name: str,
|
|
83
|
-
stage_schema: str,
|
|
84
83
|
bundle_map: BundleMap,
|
|
85
84
|
role: str,
|
|
86
85
|
prune: bool,
|
|
87
86
|
recursive: bool,
|
|
88
|
-
|
|
87
|
+
stage_path: DefaultStagePathParts,
|
|
89
88
|
local_paths_to_sync: List[Path] | None = None,
|
|
90
89
|
print_diff: bool = True,
|
|
91
90
|
) -> DiffResult:
|
|
@@ -98,32 +97,37 @@ def sync_deploy_root_with_stage(
|
|
|
98
97
|
role (str): The name of the role to use for queries and commands.
|
|
99
98
|
prune (bool): Whether to prune artifacts from the stage that don't exist locally.
|
|
100
99
|
recursive (bool): Whether to traverse directories recursively.
|
|
101
|
-
|
|
100
|
+
stage_path (DefaultStagePathParts): stage path object.
|
|
101
|
+
|
|
102
102
|
local_paths_to_sync (List[Path], optional): List of local paths to sync. Defaults to None to sync all
|
|
103
|
-
|
|
103
|
+
local paths. Note that providing an empty list here is equivalent to None.
|
|
104
104
|
print_diff (bool): Whether to print the diff between the local files and the remote stage. Defaults to True
|
|
105
105
|
|
|
106
106
|
Returns:
|
|
107
107
|
A `DiffResult` instance describing the changes that were performed.
|
|
108
108
|
"""
|
|
109
|
-
|
|
110
109
|
sql_facade = get_snowflake_facade()
|
|
110
|
+
schema = stage_path.schema
|
|
111
|
+
stage_fqn = stage_path.stage
|
|
111
112
|
# Does a stage already exist within the application package, or we need to create one?
|
|
112
113
|
# Using "if not exists" should take care of either case.
|
|
113
114
|
console.step(
|
|
114
115
|
f"Checking if stage {stage_fqn} exists, or creating a new one if none exists."
|
|
115
116
|
)
|
|
116
117
|
if not sql_facade.stage_exists(stage_fqn):
|
|
117
|
-
sql_facade.create_schema(
|
|
118
|
+
sql_facade.create_schema(schema, database=package_name)
|
|
118
119
|
sql_facade.create_stage(stage_fqn)
|
|
119
120
|
|
|
120
121
|
# Perform a diff operation and display results to the user for informational purposes
|
|
121
122
|
if print_diff:
|
|
122
123
|
console.step(
|
|
123
|
-
"Performing a diff between the Snowflake stage and your local deploy_root (
|
|
124
|
-
% deploy_root.resolve()
|
|
124
|
+
f"Performing a diff between the Snowflake stage: {stage_path.path} and your local deploy_root: {deploy_root.resolve()}."
|
|
125
125
|
)
|
|
126
|
-
|
|
126
|
+
|
|
127
|
+
diff: DiffResult = compute_stage_diff(
|
|
128
|
+
local_root=deploy_root,
|
|
129
|
+
stage_path=stage_path,
|
|
130
|
+
)
|
|
127
131
|
|
|
128
132
|
if local_paths_to_sync:
|
|
129
133
|
# Deploying specific files/directories
|
|
@@ -184,7 +188,7 @@ def sync_deploy_root_with_stage(
|
|
|
184
188
|
role=role,
|
|
185
189
|
deploy_root_path=deploy_root,
|
|
186
190
|
diff_result=diff,
|
|
187
|
-
|
|
191
|
+
stage_full_path=stage_path.full_path,
|
|
188
192
|
)
|
|
189
193
|
return diff
|
|
190
194
|
|
|
@@ -212,7 +216,10 @@ def execute_post_deploy_hooks(
|
|
|
212
216
|
|
|
213
217
|
get_cli_context().metrics.set_counter(CLICounterField.POST_DEPLOY_SCRIPTS, 1)
|
|
214
218
|
|
|
215
|
-
with
|
|
219
|
+
with (
|
|
220
|
+
console.phase(f"Executing {deployed_object_type} post-deploy actions"),
|
|
221
|
+
get_cli_context().metrics.span("post_deploy_hooks"),
|
|
222
|
+
):
|
|
216
223
|
sql_scripts_paths = []
|
|
217
224
|
display_paths = []
|
|
218
225
|
for hook in post_deploy_hooks:
|
|
@@ -321,17 +328,52 @@ def drop_generic_object(
|
|
|
321
328
|
console.message(f"Dropped {object_type} {object_name} successfully.")
|
|
322
329
|
|
|
323
330
|
|
|
324
|
-
def print_messages(
|
|
325
|
-
console: AbstractConsole, create_or_upgrade_cursor: Optional[SnowflakeCursor]
|
|
326
|
-
):
|
|
331
|
+
def print_messages(console: AbstractConsole, cursor_results: list[tuple[str]]):
|
|
327
332
|
"""
|
|
328
333
|
Shows messages in the console returned by the CREATE or UPGRADE
|
|
329
334
|
APPLICATION command.
|
|
330
335
|
"""
|
|
331
|
-
if not
|
|
336
|
+
if not cursor_results:
|
|
332
337
|
return
|
|
333
338
|
|
|
334
|
-
messages = [row[0] for row in
|
|
339
|
+
messages = [row[0] for row in cursor_results]
|
|
335
340
|
for message in messages:
|
|
336
341
|
console.warning(message)
|
|
337
342
|
console.message("")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def get_sql_executor() -> SqlExecutor:
|
|
346
|
+
"""Returns an SQL Executor that uses the connection from the current CLI context"""
|
|
347
|
+
return SqlExecutor()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class EntityActions(str, Enum):
|
|
351
|
+
BUNDLE = "action_bundle"
|
|
352
|
+
DEPLOY = "action_deploy"
|
|
353
|
+
DROP = "action_drop"
|
|
354
|
+
VALIDATE = "action_validate"
|
|
355
|
+
EVENTS = "action_events"
|
|
356
|
+
DIFF = "action_diff"
|
|
357
|
+
|
|
358
|
+
VERSION_LIST = "action_version_list"
|
|
359
|
+
VERSION_CREATE = "action_version_create"
|
|
360
|
+
VERSION_DROP = "action_version_drop"
|
|
361
|
+
|
|
362
|
+
RELEASE_DIRECTIVE_UNSET = "action_release_directive_unset"
|
|
363
|
+
RELEASE_DIRECTIVE_SET = "action_release_directive_set"
|
|
364
|
+
RELEASE_DIRECTIVE_LIST = "action_release_directive_list"
|
|
365
|
+
RELEASE_DIRECTIVE_ADD_ACCOUNTS = "action_release_directive_add_accounts"
|
|
366
|
+
RELEASE_DIRECTIVE_REMOVE_ACCOUNTS = "action_release_directive_remove_accounts"
|
|
367
|
+
|
|
368
|
+
RELEASE_CHANNEL_LIST = "action_release_channel_list"
|
|
369
|
+
RELEASE_CHANNEL_ADD_ACCOUNTS = "action_release_channel_add_accounts"
|
|
370
|
+
RELEASE_CHANNEL_REMOVE_ACCOUNTS = "action_release_channel_remove_accounts"
|
|
371
|
+
RELEASE_CHANNEL_ADD_VERSION = "action_release_channel_add_version"
|
|
372
|
+
RELEASE_CHANNEL_REMOVE_VERSION = "action_release_channel_remove_version"
|
|
373
|
+
RELEASE_CHANNEL_SET_ACCOUNTS = "action_release_channel_set_accounts"
|
|
374
|
+
|
|
375
|
+
PUBLISH = "action_publish"
|
|
376
|
+
|
|
377
|
+
@property
|
|
378
|
+
def get_action_name(self):
|
|
379
|
+
return self.value.replace("action_", "")
|