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.
Files changed (112) hide show
  1. snowflake/cli/__about__.py +1 -1
  2. snowflake/cli/_app/__main__.py +2 -2
  3. snowflake/cli/_app/cli_app.py +220 -197
  4. snowflake/cli/_app/commands_registration/builtin_plugins.py +5 -1
  5. snowflake/cli/_app/commands_registration/command_plugins_loader.py +3 -1
  6. snowflake/cli/_app/commands_registration/commands_registration_with_callbacks.py +4 -30
  7. snowflake/cli/_app/printing.py +2 -2
  8. snowflake/cli/_plugins/connection/commands.py +2 -4
  9. snowflake/cli/_plugins/cortex/commands.py +2 -4
  10. snowflake/cli/_plugins/git/manager.py +1 -1
  11. snowflake/cli/_plugins/helpers/commands.py +3 -4
  12. snowflake/cli/_plugins/nativeapp/artifacts.py +6 -624
  13. snowflake/cli/_plugins/nativeapp/bundle_context.py +1 -1
  14. snowflake/cli/_plugins/nativeapp/codegen/artifact_processor.py +1 -1
  15. snowflake/cli/_plugins/nativeapp/codegen/compiler.py +1 -3
  16. snowflake/cli/_plugins/nativeapp/codegen/setup/native_app_setup_processor.py +2 -2
  17. snowflake/cli/_plugins/nativeapp/codegen/snowpark/python_processor.py +2 -2
  18. snowflake/cli/_plugins/nativeapp/codegen/templates/templates_processor.py +2 -2
  19. snowflake/cli/_plugins/nativeapp/commands.py +21 -19
  20. snowflake/cli/_plugins/nativeapp/entities/application.py +16 -19
  21. snowflake/cli/_plugins/nativeapp/entities/application_package.py +142 -55
  22. snowflake/cli/_plugins/nativeapp/release_channel/commands.py +37 -3
  23. snowflake/cli/_plugins/nativeapp/release_directive/commands.py +80 -2
  24. snowflake/cli/_plugins/nativeapp/sf_sql_facade.py +224 -44
  25. snowflake/cli/_plugins/nativeapp/v2_conversions/compat.py +2 -2
  26. snowflake/cli/_plugins/nativeapp/version/commands.py +1 -1
  27. snowflake/cli/_plugins/notebook/commands.py +54 -2
  28. snowflake/cli/_plugins/notebook/exceptions.py +1 -1
  29. snowflake/cli/_plugins/notebook/manager.py +3 -3
  30. snowflake/cli/_plugins/notebook/notebook_entity.py +120 -0
  31. snowflake/cli/_plugins/notebook/notebook_entity_model.py +42 -0
  32. snowflake/cli/_plugins/notebook/notebook_project_paths.py +15 -0
  33. snowflake/cli/_plugins/notebook/types.py +3 -0
  34. snowflake/cli/_plugins/plugin/commands.py +79 -0
  35. snowflake/cli/_plugins/plugin/manager.py +74 -0
  36. snowflake/cli/_plugins/plugin/plugin_spec.py +30 -0
  37. snowflake/cli/_plugins/project/__init__.py +0 -0
  38. snowflake/cli/_plugins/project/commands.py +157 -0
  39. snowflake/cli/_plugins/project/feature_flags.py +22 -0
  40. snowflake/cli/_plugins/project/manager.py +76 -0
  41. snowflake/cli/_plugins/project/plugin_spec.py +30 -0
  42. snowflake/cli/_plugins/project/project_entity_model.py +40 -0
  43. snowflake/cli/_plugins/snowpark/commands.py +49 -30
  44. snowflake/cli/_plugins/snowpark/common.py +47 -2
  45. snowflake/cli/_plugins/snowpark/snowpark_entity.py +38 -25
  46. snowflake/cli/_plugins/snowpark/snowpark_entity_model.py +18 -30
  47. snowflake/cli/_plugins/snowpark/snowpark_project_paths.py +156 -23
  48. snowflake/cli/_plugins/snowpark/zipper.py +33 -1
  49. snowflake/cli/_plugins/spcs/compute_pool/commands.py +53 -5
  50. snowflake/cli/_plugins/spcs/compute_pool/compute_pool_entity.py +8 -0
  51. snowflake/cli/_plugins/spcs/compute_pool/compute_pool_entity_model.py +37 -0
  52. snowflake/cli/_plugins/spcs/compute_pool/manager.py +45 -0
  53. snowflake/cli/_plugins/spcs/image_repository/commands.py +29 -0
  54. snowflake/cli/_plugins/spcs/image_repository/image_repository_entity.py +8 -0
  55. snowflake/cli/_plugins/spcs/image_repository/image_repository_entity_model.py +8 -0
  56. snowflake/cli/_plugins/spcs/image_repository/manager.py +1 -1
  57. snowflake/cli/_plugins/spcs/services/commands.py +51 -1
  58. snowflake/cli/_plugins/spcs/services/manager.py +114 -0
  59. snowflake/cli/_plugins/spcs/services/service_entity.py +6 -0
  60. snowflake/cli/_plugins/spcs/services/service_entity_model.py +45 -0
  61. snowflake/cli/_plugins/spcs/services/service_project_paths.py +15 -0
  62. snowflake/cli/_plugins/stage/commands.py +2 -1
  63. snowflake/cli/_plugins/stage/diff.py +60 -39
  64. snowflake/cli/_plugins/stage/manager.py +26 -13
  65. snowflake/cli/_plugins/stage/utils.py +1 -1
  66. snowflake/cli/_plugins/streamlit/commands.py +18 -24
  67. snowflake/cli/_plugins/streamlit/manager.py +37 -27
  68. snowflake/cli/_plugins/streamlit/streamlit_entity.py +20 -41
  69. snowflake/cli/_plugins/streamlit/streamlit_entity_model.py +14 -24
  70. snowflake/cli/_plugins/streamlit/streamlit_project_paths.py +30 -0
  71. snowflake/cli/_plugins/workspace/commands.py +3 -3
  72. snowflake/cli/_plugins/workspace/manager.py +1 -1
  73. snowflake/cli/api/artifacts/bundle_map.py +500 -0
  74. snowflake/cli/api/artifacts/common.py +78 -0
  75. snowflake/cli/api/artifacts/upload.py +51 -0
  76. snowflake/cli/api/artifacts/utils.py +82 -0
  77. snowflake/cli/api/cli_global_context.py +14 -1
  78. snowflake/cli/api/commands/flags.py +34 -13
  79. snowflake/cli/api/commands/snow_typer.py +12 -0
  80. snowflake/cli/api/commands/utils.py +30 -2
  81. snowflake/cli/api/config.py +15 -10
  82. snowflake/cli/api/constants.py +1 -0
  83. snowflake/cli/api/entities/common.py +14 -32
  84. snowflake/cli/api/entities/resolver.py +160 -0
  85. snowflake/cli/api/entities/utils.py +56 -15
  86. snowflake/cli/api/errno.py +3 -0
  87. snowflake/cli/api/exceptions.py +8 -1
  88. snowflake/cli/api/feature_flags.py +1 -1
  89. snowflake/cli/api/plugins/plugin_config.py +43 -4
  90. snowflake/cli/api/project/definition_conversion.py +3 -2
  91. snowflake/cli/api/project/definition_helper.py +31 -0
  92. snowflake/cli/api/project/project_paths.py +28 -0
  93. snowflake/cli/api/project/schemas/entities/common.py +130 -1
  94. snowflake/cli/api/project/schemas/entities/entities.py +30 -0
  95. snowflake/cli/api/project/schemas/project_definition.py +27 -0
  96. snowflake/cli/api/project/schemas/updatable_model.py +2 -2
  97. snowflake/cli/api/project/schemas/v1/native_app/native_app.py +5 -7
  98. snowflake/cli/api/secure_path.py +6 -0
  99. snowflake/cli/api/sql_execution.py +5 -1
  100. snowflake/cli/api/stage_path.py +7 -2
  101. snowflake/cli/api/utils/graph.py +3 -0
  102. snowflake/cli/api/utils/path_utils.py +24 -0
  103. {snowflake_cli-3.3.0.dist-info → snowflake_cli-3.5.0.dist-info}/METADATA +12 -13
  104. {snowflake_cli-3.3.0.dist-info → snowflake_cli-3.5.0.dist-info}/RECORD +109 -85
  105. snowflake/cli/_app/api_impl/plugin/plugin_config_provider_impl.py +0 -66
  106. snowflake/cli/api/__init__.py +0 -48
  107. snowflake/cli/api/project/schemas/v1/native_app/path_mapping.py +0 -65
  108. /snowflake/cli/{_app/api_impl → _plugins/plugin}/__init__.py +0 -0
  109. /snowflake/cli/{_app/api_impl/plugin → api/artifacts}/__init__.py +0 -0
  110. {snowflake_cli-3.3.0.dist-info → snowflake_cli-3.5.0.dist-info}/WHEEL +0 -0
  111. {snowflake_cli-3.3.0.dist-info → snowflake_cli-3.5.0.dist-info}/entry_points.txt +0 -0
  112. {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="Run Python connector diagnostic test",
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 = "<temporary_directory>"
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
- get_cli_context_manager().connection_context.diag_log_path = Path(path)
296
- return path
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="Diagnostic report path",
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="Diagnostic report path to optional allowlist",
314
- callback=_connection_callback("diag_allowlist_path"),
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 resides. "
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 of key=value. Overrides variables from env section used for templates.",
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 typing import List, Optional
1
+ from __future__ import annotations
2
2
 
3
- from click import ClickException
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]
@@ -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
- section=CONNECTIONS_SECTION,
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(section: str | None, key: str, value: Any):
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
- if section:
206
- if conf_file_cache.get(section) is None:
207
- conf_file_cache[section] = {}
208
- conf_file_cache[section][key] = value
209
- else:
210
- conf_file_cache[key] = value
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:
@@ -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