snowflake-cli 3.3.0__py3-none-any.whl → 3.5.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.
- snowflake/cli/__about__.py +1 -1
- snowflake/cli/_app/__main__.py +2 -2
- snowflake/cli/_app/cli_app.py +220 -197
- snowflake/cli/_app/commands_registration/builtin_plugins.py +5 -1
- snowflake/cli/_app/commands_registration/command_plugins_loader.py +3 -1
- snowflake/cli/_app/commands_registration/commands_registration_with_callbacks.py +4 -30
- snowflake/cli/_app/printing.py +2 -2
- snowflake/cli/_plugins/connection/commands.py +2 -4
- snowflake/cli/_plugins/cortex/commands.py +2 -4
- snowflake/cli/_plugins/git/manager.py +1 -1
- snowflake/cli/_plugins/helpers/commands.py +3 -4
- snowflake/cli/_plugins/nativeapp/artifacts.py +6 -624
- 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 +1 -3
- snowflake/cli/_plugins/nativeapp/codegen/setup/native_app_setup_processor.py +2 -2
- snowflake/cli/_plugins/nativeapp/codegen/snowpark/python_processor.py +2 -2
- snowflake/cli/_plugins/nativeapp/codegen/templates/templates_processor.py +2 -2
- snowflake/cli/_plugins/nativeapp/commands.py +21 -19
- snowflake/cli/_plugins/nativeapp/entities/application.py +16 -19
- snowflake/cli/_plugins/nativeapp/entities/application_package.py +142 -55
- snowflake/cli/_plugins/nativeapp/release_channel/commands.py +37 -3
- snowflake/cli/_plugins/nativeapp/release_directive/commands.py +80 -2
- snowflake/cli/_plugins/nativeapp/sf_sql_facade.py +224 -44
- snowflake/cli/_plugins/nativeapp/v2_conversions/compat.py +2 -2
- snowflake/cli/_plugins/nativeapp/version/commands.py +1 -1
- snowflake/cli/_plugins/notebook/commands.py +54 -2
- snowflake/cli/_plugins/notebook/exceptions.py +1 -1
- snowflake/cli/_plugins/notebook/manager.py +3 -3
- 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/plugin/commands.py +79 -0
- snowflake/cli/_plugins/plugin/manager.py +74 -0
- snowflake/cli/_plugins/plugin/plugin_spec.py +30 -0
- snowflake/cli/_plugins/project/__init__.py +0 -0
- snowflake/cli/_plugins/project/commands.py +157 -0
- snowflake/cli/_plugins/project/feature_flags.py +22 -0
- snowflake/cli/_plugins/project/manager.py +76 -0
- snowflake/cli/_plugins/project/plugin_spec.py +30 -0
- snowflake/cli/_plugins/project/project_entity_model.py +40 -0
- snowflake/cli/_plugins/snowpark/commands.py +49 -30
- snowflake/cli/_plugins/snowpark/common.py +47 -2
- snowflake/cli/_plugins/snowpark/snowpark_entity.py +38 -25
- 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/compute_pool/commands.py +53 -5
- snowflake/cli/_plugins/spcs/compute_pool/compute_pool_entity.py +8 -0
- snowflake/cli/_plugins/spcs/compute_pool/compute_pool_entity_model.py +37 -0
- snowflake/cli/_plugins/spcs/compute_pool/manager.py +45 -0
- snowflake/cli/_plugins/spcs/image_repository/commands.py +29 -0
- snowflake/cli/_plugins/spcs/image_repository/image_repository_entity.py +8 -0
- snowflake/cli/_plugins/spcs/image_repository/image_repository_entity_model.py +8 -0
- snowflake/cli/_plugins/spcs/image_repository/manager.py +1 -1
- snowflake/cli/_plugins/spcs/services/commands.py +51 -1
- snowflake/cli/_plugins/spcs/services/manager.py +114 -0
- snowflake/cli/_plugins/spcs/services/service_entity.py +6 -0
- snowflake/cli/_plugins/spcs/services/service_entity_model.py +45 -0
- snowflake/cli/_plugins/spcs/services/service_project_paths.py +15 -0
- snowflake/cli/_plugins/stage/commands.py +2 -1
- snowflake/cli/_plugins/stage/diff.py +60 -39
- snowflake/cli/_plugins/stage/manager.py +26 -13
- snowflake/cli/_plugins/stage/utils.py +1 -1
- snowflake/cli/_plugins/streamlit/commands.py +18 -24
- snowflake/cli/_plugins/streamlit/manager.py +37 -27
- snowflake/cli/_plugins/streamlit/streamlit_entity.py +20 -41
- 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 +3 -3
- snowflake/cli/_plugins/workspace/manager.py +1 -1
- snowflake/cli/api/artifacts/bundle_map.py +500 -0
- snowflake/cli/api/artifacts/common.py +78 -0
- snowflake/cli/api/artifacts/upload.py +51 -0
- snowflake/cli/api/artifacts/utils.py +82 -0
- snowflake/cli/api/cli_global_context.py +14 -1
- snowflake/cli/api/commands/flags.py +34 -13
- snowflake/cli/api/commands/snow_typer.py +12 -0
- snowflake/cli/api/commands/utils.py +30 -2
- snowflake/cli/api/config.py +15 -10
- snowflake/cli/api/constants.py +1 -0
- snowflake/cli/api/entities/common.py +14 -32
- snowflake/cli/api/entities/resolver.py +160 -0
- snowflake/cli/api/entities/utils.py +56 -15
- snowflake/cli/api/errno.py +3 -0
- snowflake/cli/api/exceptions.py +8 -1
- snowflake/cli/api/feature_flags.py +1 -1
- snowflake/cli/api/plugins/plugin_config.py +43 -4
- snowflake/cli/api/project/definition_conversion.py +3 -2
- snowflake/cli/api/project/definition_helper.py +31 -0
- 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 +30 -0
- snowflake/cli/api/project/schemas/project_definition.py +27 -0
- 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/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.3.0.dist-info → snowflake_cli-3.5.0.dist-info}/METADATA +12 -13
- {snowflake_cli-3.3.0.dist-info → snowflake_cli-3.5.0.dist-info}/RECORD +109 -85
- snowflake/cli/_app/api_impl/plugin/plugin_config_provider_impl.py +0 -66
- snowflake/cli/api/__init__.py +0 -48
- snowflake/cli/api/project/schemas/v1/native_app/path_mapping.py +0 -65
- /snowflake/cli/{_app/api_impl → _plugins/plugin}/__init__.py +0 -0
- /snowflake/cli/{_app/api_impl/plugin → api/artifacts}/__init__.py +0 -0
- {snowflake_cli-3.3.0.dist-info → snowflake_cli-3.5.0.dist-info}/WHEEL +0 -0
- {snowflake_cli-3.3.0.dist-info → snowflake_cli-3.5.0.dist-info}/entry_points.txt +0 -0
- {snowflake_cli-3.3.0.dist-info → snowflake_cli-3.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from snowflake.cli.api.artifacts.bundle_map import BundleMap
|
|
7
|
+
from snowflake.cli.api.artifacts.common import NotInDeployRootError
|
|
8
|
+
from snowflake.cli.api.project.project_paths import ProjectPaths
|
|
9
|
+
from snowflake.cli.api.project.schemas.entities.common import Artifacts
|
|
10
|
+
from snowflake.cli.api.secure_path import SecurePath
|
|
11
|
+
from snowflake.cli.api.utils.path_utils import delete, resolve_without_follow
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def symlink_or_copy(src: Path, dst: Path, deploy_root: Path) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Symlinks files from src to dst. If the src contains parent directories, then copies the empty directory shell to the deploy root.
|
|
17
|
+
The directory hierarchy above dst is created if any of those directories do not exist.
|
|
18
|
+
"""
|
|
19
|
+
ssrc = SecurePath(src)
|
|
20
|
+
sdst = SecurePath(dst)
|
|
21
|
+
sdst.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
|
|
23
|
+
# Verify that the mapping isn't accidentally trying to create a file in the project source through symlinks.
|
|
24
|
+
# We need to ensure we're resolving symlinks for this check to be effective.
|
|
25
|
+
# We are unlikely to hit this if calling the function through bundle map, keeping it here for other future use cases outside bundle.
|
|
26
|
+
resolved_dst = dst.resolve()
|
|
27
|
+
resolved_deploy_root = deploy_root.resolve()
|
|
28
|
+
dst_is_deploy_root = resolved_deploy_root == resolved_dst
|
|
29
|
+
if (not dst_is_deploy_root) and (resolved_deploy_root not in resolved_dst.parents):
|
|
30
|
+
raise NotInDeployRootError(dest_path=dst, deploy_root=deploy_root, src_path=src)
|
|
31
|
+
|
|
32
|
+
absolute_src = resolve_without_follow(src)
|
|
33
|
+
if absolute_src.is_file():
|
|
34
|
+
delete(dst)
|
|
35
|
+
try:
|
|
36
|
+
os.symlink(absolute_src, dst)
|
|
37
|
+
except OSError:
|
|
38
|
+
ssrc.copy(dst)
|
|
39
|
+
else:
|
|
40
|
+
# 1. Create a new directory in the deploy root
|
|
41
|
+
sdst.mkdir(exist_ok=True)
|
|
42
|
+
# 2. For all children of src, create their counterparts in dst now that it exists
|
|
43
|
+
for root, _, files in sorted(os.walk(absolute_src, followlinks=True)):
|
|
44
|
+
relative_root = Path(root).relative_to(absolute_src)
|
|
45
|
+
absolute_root_in_deploy = Path(dst, relative_root)
|
|
46
|
+
SecurePath(absolute_root_in_deploy).mkdir(parents=True, exist_ok=True)
|
|
47
|
+
for file in sorted(files):
|
|
48
|
+
absolute_file_in_project = Path(absolute_src, relative_root, file)
|
|
49
|
+
absolute_file_in_deploy = Path(absolute_root_in_deploy, file)
|
|
50
|
+
symlink_or_copy(
|
|
51
|
+
src=absolute_file_in_project,
|
|
52
|
+
dst=absolute_file_in_deploy,
|
|
53
|
+
deploy_root=deploy_root,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def bundle_artifacts(project_paths: ProjectPaths, artifacts: Artifacts) -> BundleMap:
|
|
58
|
+
"""
|
|
59
|
+
Creates a bundle directory (project_paths.bundle_root) with all artifacts (using symlink_or_copy function above).
|
|
60
|
+
Previous contents of the directory are deleted.
|
|
61
|
+
|
|
62
|
+
Returns a BundleMap containing the mapping between artifacts and their location in bundle directory.
|
|
63
|
+
"""
|
|
64
|
+
bundle_map = BundleMap(
|
|
65
|
+
project_root=project_paths.project_root,
|
|
66
|
+
deploy_root=project_paths.bundle_root,
|
|
67
|
+
)
|
|
68
|
+
for artifact in artifacts:
|
|
69
|
+
bundle_map.add(artifact)
|
|
70
|
+
|
|
71
|
+
project_paths.remove_up_bundle_root()
|
|
72
|
+
for absolute_src, absolute_dest in bundle_map.all_mappings(
|
|
73
|
+
absolute=True, expand_directories=True
|
|
74
|
+
):
|
|
75
|
+
# We treat the bundle root as deploy root
|
|
76
|
+
symlink_or_copy(
|
|
77
|
+
absolute_src,
|
|
78
|
+
absolute_dest,
|
|
79
|
+
deploy_root=project_paths.bundle_root,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return bundle_map
|
|
@@ -19,7 +19,7 @@ from contextvars import ContextVar
|
|
|
19
19
|
from dataclasses import dataclass, field, replace
|
|
20
20
|
from functools import wraps
|
|
21
21
|
from pathlib import Path
|
|
22
|
-
from typing import TYPE_CHECKING, Iterator
|
|
22
|
+
from typing import TYPE_CHECKING, Iterator, Optional
|
|
23
23
|
|
|
24
24
|
from snowflake.cli.api.connections import ConnectionContext, OpenConnectionCache
|
|
25
25
|
from snowflake.cli.api.exceptions import MissingConfiguration
|
|
@@ -196,6 +196,19 @@ class _CliGlobalContextAccess:
|
|
|
196
196
|
"""Computes whether cli_console output should be muted."""
|
|
197
197
|
return self._manager.output_format == OutputFormat.JSON
|
|
198
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
|
+
|
|
199
212
|
|
|
200
213
|
_CLI_CONTEXT_MANAGER: ContextVar[_CliGlobalContextManager | None] = ContextVar(
|
|
201
214
|
"cli_context", default=None
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
-
import tempfile
|
|
18
17
|
from pathlib import Path
|
|
19
18
|
from typing import Any, Callable, Optional
|
|
20
19
|
|
|
@@ -95,7 +94,7 @@ TemporaryConnectionOption = typer.Option(
|
|
|
95
94
|
False,
|
|
96
95
|
"--temporary-connection",
|
|
97
96
|
"-x",
|
|
98
|
-
help="Uses connection defined with command line parameters, instead of one defined in config",
|
|
97
|
+
help="Uses a connection defined with command line parameters, instead of one defined in config",
|
|
99
98
|
callback=_connection_callback("temporary_connection"),
|
|
100
99
|
is_flag=True,
|
|
101
100
|
rich_help_panel=_CONNECTION_SECTION,
|
|
@@ -277,7 +276,7 @@ MfaPasscodeOption = typer.Option(
|
|
|
277
276
|
EnableDiagOption = typer.Option(
|
|
278
277
|
False,
|
|
279
278
|
"--enable-diag",
|
|
280
|
-
help="
|
|
279
|
+
help="Whether to generate a connection diagnostic report.",
|
|
281
280
|
callback=_connection_callback("enable_diag"),
|
|
282
281
|
show_default=False,
|
|
283
282
|
is_flag=True,
|
|
@@ -286,20 +285,29 @@ EnableDiagOption = typer.Option(
|
|
|
286
285
|
|
|
287
286
|
# Set default via callback to avoid including tempdir path in generated docs (snow --docs).
|
|
288
287
|
# Use constant instead of None, as None is removed from telemetry data.
|
|
289
|
-
_DIAG_LOG_DEFAULT_VALUE = "<
|
|
288
|
+
_DIAG_LOG_DEFAULT_VALUE = "<system_temporary_directory>"
|
|
290
289
|
|
|
291
290
|
|
|
292
291
|
def _diag_log_path_callback(path: str):
|
|
293
292
|
if path == _DIAG_LOG_DEFAULT_VALUE:
|
|
293
|
+
import tempfile
|
|
294
|
+
|
|
294
295
|
path = tempfile.gettempdir()
|
|
295
|
-
|
|
296
|
-
|
|
296
|
+
|
|
297
|
+
absolute_path = Path(path).absolute()
|
|
298
|
+
if not absolute_path.exists():
|
|
299
|
+
# if the path does not exist the report is not generated
|
|
300
|
+
from snowflake.cli.api.secure_path import SecurePath
|
|
301
|
+
|
|
302
|
+
SecurePath(absolute_path).mkdir(parents=True)
|
|
303
|
+
|
|
304
|
+
return _connection_callback("diag_log_path")(absolute_path)
|
|
297
305
|
|
|
298
306
|
|
|
299
307
|
DiagLogPathOption: Path = typer.Option(
|
|
300
308
|
_DIAG_LOG_DEFAULT_VALUE,
|
|
301
309
|
"--diag-log-path",
|
|
302
|
-
help="
|
|
310
|
+
help="Path for the generated report. Defaults to system temporary directory.",
|
|
303
311
|
callback=_diag_log_path_callback,
|
|
304
312
|
show_default=False,
|
|
305
313
|
rich_help_panel=_CONNECTION_SECTION,
|
|
@@ -307,11 +315,17 @@ DiagLogPathOption: Path = typer.Option(
|
|
|
307
315
|
writable=True,
|
|
308
316
|
)
|
|
309
317
|
|
|
318
|
+
|
|
319
|
+
def _diag_log_allowlist_path_callback(path: str):
|
|
320
|
+
absolute_path = Path(path).absolute() if path else None
|
|
321
|
+
return _connection_callback("diag_allowlist_path")(absolute_path)
|
|
322
|
+
|
|
323
|
+
|
|
310
324
|
DiagAllowlistPathOption: Path = typer.Option(
|
|
311
325
|
None,
|
|
312
326
|
"--diag-allowlist-path",
|
|
313
|
-
help="
|
|
314
|
-
callback=
|
|
327
|
+
help="Path to a JSON file containing allowlist parameters.",
|
|
328
|
+
callback=_diag_log_allowlist_path_callback,
|
|
315
329
|
show_default=False,
|
|
316
330
|
rich_help_panel=_CONNECTION_SECTION,
|
|
317
331
|
exists=True,
|
|
@@ -383,6 +397,12 @@ ReplaceOption = OverrideableOption(
|
|
|
383
397
|
mutually_exclusive=CREATE_MODE_OPTION_NAMES,
|
|
384
398
|
)
|
|
385
399
|
|
|
400
|
+
ForceReplaceOption = OverrideableOption(
|
|
401
|
+
False,
|
|
402
|
+
"--force-replace",
|
|
403
|
+
help="Replace this object, even if the state didn't change",
|
|
404
|
+
)
|
|
405
|
+
|
|
386
406
|
OnErrorOption = typer.Option(
|
|
387
407
|
OnErrorType.BREAK.value,
|
|
388
408
|
"--on-error",
|
|
@@ -486,9 +506,10 @@ def identifier_argument(
|
|
|
486
506
|
example: str,
|
|
487
507
|
click_type: click.ParamType = IdentifierType(),
|
|
488
508
|
callback: Callable | None = None,
|
|
509
|
+
is_optional: bool = False,
|
|
489
510
|
) -> typer.Argument:
|
|
490
511
|
return typer.Argument(
|
|
491
|
-
...,
|
|
512
|
+
None if is_optional else ...,
|
|
492
513
|
help=f"Identifier of the {sf_object}; for example: {example}",
|
|
493
514
|
show_default=False,
|
|
494
515
|
click_type=click_type,
|
|
@@ -531,8 +552,8 @@ def project_definition_option(is_optional: bool):
|
|
|
531
552
|
None,
|
|
532
553
|
"-p",
|
|
533
554
|
"--project",
|
|
534
|
-
help=f"Path where Snowflake project
|
|
535
|
-
f"Defaults to current working directory.",
|
|
555
|
+
help=f"Path where the Snowflake project is stored. "
|
|
556
|
+
f"Defaults to the current working directory.",
|
|
536
557
|
callback=project_path_callback,
|
|
537
558
|
show_default=False,
|
|
538
559
|
)
|
|
@@ -551,7 +572,7 @@ def project_env_overrides_option():
|
|
|
551
572
|
return typer.Option(
|
|
552
573
|
[],
|
|
553
574
|
"--env",
|
|
554
|
-
help="String in format
|
|
575
|
+
help="String in the format key=value. Overrides variables from the env section used for templates.",
|
|
555
576
|
callback=project_env_overrides_callback,
|
|
556
577
|
show_default=False,
|
|
557
578
|
)
|
|
@@ -19,6 +19,7 @@ import logging
|
|
|
19
19
|
from functools import wraps
|
|
20
20
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
21
21
|
|
|
22
|
+
import click
|
|
22
23
|
import typer
|
|
23
24
|
from click import ClickException
|
|
24
25
|
from snowflake.cli.api.commands.decorators import (
|
|
@@ -35,10 +36,20 @@ from snowflake.cli.api.output.types import CommandResult
|
|
|
35
36
|
from snowflake.cli.api.sanitizers import sanitize_for_terminal
|
|
36
37
|
from snowflake.cli.api.sql_execution import SqlExecutionMixin
|
|
37
38
|
from snowflake.connector import DatabaseError
|
|
39
|
+
from typer.core import TyperGroup
|
|
38
40
|
|
|
39
41
|
log = logging.getLogger(__name__)
|
|
40
42
|
|
|
41
43
|
|
|
44
|
+
class SortedTyperGroup(TyperGroup):
|
|
45
|
+
def list_commands(self, ctx: click.Context) -> List[str]:
|
|
46
|
+
"""
|
|
47
|
+
From Typer 0.13.0 help items are in order of definition, this function override that approach.
|
|
48
|
+
Returns a list of subcommand names in the alphabetic order.
|
|
49
|
+
"""
|
|
50
|
+
return sorted(self.commands)
|
|
51
|
+
|
|
52
|
+
|
|
42
53
|
class SnowTyper(typer.Typer):
|
|
43
54
|
def __init__(self, /, **kwargs):
|
|
44
55
|
self._sanitize_kwargs(kwargs)
|
|
@@ -49,6 +60,7 @@ class SnowTyper(typer.Typer):
|
|
|
49
60
|
no_args_is_help=True,
|
|
50
61
|
add_completion=True,
|
|
51
62
|
rich_markup_mode="markdown",
|
|
63
|
+
cls=SortedTyperGroup,
|
|
52
64
|
)
|
|
53
65
|
|
|
54
66
|
@staticmethod
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from typing import Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from click import ClickException, UsageError
|
|
4
6
|
from snowflake.cli.api.commands.common import Variable
|
|
7
|
+
from snowflake.cli.api.exceptions import NoProjectDefinitionError
|
|
8
|
+
from snowflake.cli.api.project.schemas.entities.common import EntityModelBase
|
|
5
9
|
|
|
6
10
|
|
|
7
11
|
def parse_key_value_variables(variables: Optional[List[str]]) -> List[Variable]:
|
|
@@ -16,3 +20,27 @@ def parse_key_value_variables(variables: Optional[List[str]]) -> List[Variable]:
|
|
|
16
20
|
key, value = p.split("=", 1)
|
|
17
21
|
result.append(Variable(key.strip(), value.strip()))
|
|
18
22
|
return result
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_entity_for_operation(
|
|
26
|
+
cli_context,
|
|
27
|
+
entity_id: str | None,
|
|
28
|
+
project_definition,
|
|
29
|
+
entity_type: str,
|
|
30
|
+
):
|
|
31
|
+
entities: Dict[str, EntityModelBase] = project_definition.get_entities_by_type(
|
|
32
|
+
entity_type=entity_type
|
|
33
|
+
)
|
|
34
|
+
if not entities:
|
|
35
|
+
raise NoProjectDefinitionError(
|
|
36
|
+
project_type=entity_type, project_root=cli_context.project_root
|
|
37
|
+
)
|
|
38
|
+
if entity_id and entity_id not in entities:
|
|
39
|
+
raise UsageError(f"No '{entity_id}' entity in project definition file.")
|
|
40
|
+
if len(entities.keys()) == 1:
|
|
41
|
+
entity_id = list(entities.keys())[0]
|
|
42
|
+
if entity_id is None:
|
|
43
|
+
raise UsageError(
|
|
44
|
+
f"Multiple entities of type {entity_type} found. Please provide entity id for the operation."
|
|
45
|
+
)
|
|
46
|
+
return entities[entity_id]
|
snowflake/cli/api/config.py
CHANGED
|
@@ -20,7 +20,7 @@ import warnings
|
|
|
20
20
|
from contextlib import contextmanager
|
|
21
21
|
from dataclasses import asdict, dataclass, field
|
|
22
22
|
from pathlib import Path
|
|
23
|
-
from typing import Any, Dict, Optional, Union
|
|
23
|
+
from typing import Any, Dict, List, Optional, Union
|
|
24
24
|
|
|
25
25
|
import tomlkit
|
|
26
26
|
from click import ClickException
|
|
@@ -58,6 +58,7 @@ PLUGINS_SECTION = "plugins"
|
|
|
58
58
|
|
|
59
59
|
LOGS_SECTION_PATH = [CLI_SECTION, LOGS_SECTION]
|
|
60
60
|
PLUGINS_SECTION_PATH = [CLI_SECTION, PLUGINS_SECTION]
|
|
61
|
+
PLUGIN_ENABLED_KEY = "enabled"
|
|
61
62
|
FEATURE_FLAGS_SECTION_PATH = [CLI_SECTION, "features"]
|
|
62
63
|
|
|
63
64
|
CONFIG_MANAGER.add_option(
|
|
@@ -140,8 +141,7 @@ def add_connection_to_proper_file(name: str, connection_config: ConnectionConfig
|
|
|
140
141
|
return CONNECTIONS_FILE
|
|
141
142
|
else:
|
|
142
143
|
set_config_value(
|
|
143
|
-
|
|
144
|
-
key=name,
|
|
144
|
+
path=[CONNECTIONS_SECTION, name],
|
|
145
145
|
value=connection_config.to_dict_of_all_non_empty_values(),
|
|
146
146
|
)
|
|
147
147
|
return CONFIG_MANAGER.file_path
|
|
@@ -200,14 +200,19 @@ def _initialise_logs_section():
|
|
|
200
200
|
conf_file_cache[CLI_SECTION][LOGS_SECTION] = _DEFAULT_LOGS_CONFIG
|
|
201
201
|
|
|
202
202
|
|
|
203
|
-
def set_config_value(
|
|
203
|
+
def set_config_value(path: List[str], value: Any) -> None:
|
|
204
|
+
"""Sets value in config.
|
|
205
|
+
For example to set value "val" to key "key" in section [a.b.c], call
|
|
206
|
+
set_config_value(["a", "b", "c", "key"], "val").
|
|
207
|
+
If you want to override a whole section, value should be a dictionary.
|
|
208
|
+
"""
|
|
204
209
|
with _config_file() as conf_file_cache:
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
current_config_dict = conf_file_cache
|
|
211
|
+
for key in path[:-1]:
|
|
212
|
+
if key not in current_config_dict:
|
|
213
|
+
current_config_dict[key] = {}
|
|
214
|
+
current_config_dict = current_config_dict[key]
|
|
215
|
+
current_config_dict[path[-1]] = value
|
|
211
216
|
|
|
212
217
|
|
|
213
218
|
def get_logs_config() -> dict:
|
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")
|
|
@@ -1,40 +1,16 @@
|
|
|
1
1
|
import functools
|
|
2
|
-
from enum import Enum
|
|
3
2
|
from pathlib import Path
|
|
4
3
|
from typing import Generic, Type, TypeVar, get_args
|
|
5
4
|
|
|
6
5
|
from snowflake.cli._plugins.workspace.context import ActionContext, WorkspaceContext
|
|
7
|
-
from snowflake.cli.api.cli_global_context import span
|
|
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
|
|
8
9
|
from snowflake.cli.api.identifiers import FQN
|
|
9
10
|
from snowflake.cli.api.sql_execution import SqlExecutor
|
|
10
11
|
from snowflake.connector import SnowflakeConnection
|
|
11
12
|
from snowflake.connector.cursor import SnowflakeCursor
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
class EntityActions(str, Enum):
|
|
15
|
-
BUNDLE = "action_bundle"
|
|
16
|
-
DEPLOY = "action_deploy"
|
|
17
|
-
DROP = "action_drop"
|
|
18
|
-
VALIDATE = "action_validate"
|
|
19
|
-
EVENTS = "action_events"
|
|
20
|
-
|
|
21
|
-
VERSION_LIST = "action_version_list"
|
|
22
|
-
VERSION_CREATE = "action_version_create"
|
|
23
|
-
VERSION_DROP = "action_version_drop"
|
|
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
|
-
|
|
37
|
-
|
|
38
14
|
T = TypeVar("T")
|
|
39
15
|
|
|
40
16
|
|
|
@@ -71,6 +47,7 @@ class EntityBase(Generic[T]):
|
|
|
71
47
|
def __init__(self, entity_model: T, workspace_ctx: WorkspaceContext):
|
|
72
48
|
self._entity_model = entity_model
|
|
73
49
|
self._workspace_ctx = workspace_ctx
|
|
50
|
+
self.dependency_resolver = DependencyResolver(entity_model)
|
|
74
51
|
|
|
75
52
|
@property
|
|
76
53
|
def entity_id(self) -> str:
|
|
@@ -96,7 +73,10 @@ class EntityBase(Generic[T]):
|
|
|
96
73
|
):
|
|
97
74
|
"""
|
|
98
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.
|
|
99
78
|
"""
|
|
79
|
+
self.dependency_resolver.perform_for_dep(action, action_ctx, *args, **kwargs)
|
|
100
80
|
return getattr(self, action)(action_ctx, *args, **kwargs)
|
|
101
81
|
|
|
102
82
|
@property
|
|
@@ -124,10 +104,17 @@ class EntityBase(Generic[T]):
|
|
|
124
104
|
def _conn(self) -> SnowflakeConnection:
|
|
125
105
|
return self._sql_executor._conn # noqa
|
|
126
106
|
|
|
107
|
+
@property
|
|
108
|
+
def snow_api_root(self):
|
|
109
|
+
return get_cli_context().snow_api_root
|
|
110
|
+
|
|
127
111
|
@property
|
|
128
112
|
def model(self):
|
|
129
113
|
return self._entity_model
|
|
130
114
|
|
|
115
|
+
def dependent_entities(self, action_ctx: ActionContext):
|
|
116
|
+
return self.dependency_resolver.depends_on(action_ctx)
|
|
117
|
+
|
|
131
118
|
def get_usage_grant_sql(self, app_role: str) -> str:
|
|
132
119
|
return f"GRANT USAGE ON {self.model.type.upper()} {self.identifier} TO ROLE {app_role};"
|
|
133
120
|
|
|
@@ -136,8 +123,3 @@ class EntityBase(Generic[T]):
|
|
|
136
123
|
|
|
137
124
|
def get_drop_sql(self) -> str:
|
|
138
125
|
return f"DROP {self.model.type.upper()} {self.identifier};"
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def get_sql_executor() -> SqlExecutor:
|
|
142
|
-
"""Returns an SQL Executor that uses the connection from the current CLI context"""
|
|
143
|
-
return SqlExecutor()
|
|
@@ -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
|