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.
Files changed (97) hide show
  1. snowflake/cli/__about__.py +1 -1
  2. snowflake/cli/_app/__main__.py +2 -2
  3. snowflake/cli/_app/cli_app.py +224 -192
  4. snowflake/cli/_app/commands_registration/commands_registration_with_callbacks.py +1 -27
  5. snowflake/cli/_app/constants.py +4 -0
  6. snowflake/cli/_app/snow_connector.py +12 -0
  7. snowflake/cli/_app/telemetry.py +10 -3
  8. snowflake/cli/_plugins/connection/util.py +12 -19
  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 +207 -1
  12. snowflake/cli/_plugins/nativeapp/artifacts.py +16 -628
  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 +42 -20
  16. snowflake/cli/_plugins/nativeapp/codegen/setup/native_app_setup_processor.py +9 -2
  17. snowflake/cli/_plugins/nativeapp/codegen/snowpark/python_processor.py +6 -3
  18. snowflake/cli/_plugins/nativeapp/codegen/templates/templates_processor.py +44 -34
  19. snowflake/cli/_plugins/nativeapp/commands.py +113 -21
  20. snowflake/cli/_plugins/nativeapp/constants.py +5 -0
  21. snowflake/cli/_plugins/nativeapp/entities/application.py +226 -296
  22. snowflake/cli/_plugins/nativeapp/entities/application_package.py +911 -141
  23. snowflake/cli/_plugins/nativeapp/entities/application_package_child_interface.py +43 -0
  24. snowflake/cli/_plugins/nativeapp/feature_flags.py +5 -1
  25. snowflake/cli/_plugins/nativeapp/release_channel/__init__.py +13 -0
  26. snowflake/cli/_plugins/nativeapp/release_channel/commands.py +246 -0
  27. snowflake/cli/_plugins/nativeapp/release_directive/__init__.py +13 -0
  28. snowflake/cli/_plugins/nativeapp/release_directive/commands.py +243 -0
  29. snowflake/cli/_plugins/nativeapp/same_account_install_method.py +9 -17
  30. snowflake/cli/_plugins/nativeapp/sf_facade_exceptions.py +80 -0
  31. snowflake/cli/_plugins/nativeapp/sf_sql_facade.py +1184 -80
  32. snowflake/cli/_plugins/nativeapp/utils.py +11 -0
  33. snowflake/cli/_plugins/nativeapp/v2_conversions/compat.py +7 -3
  34. snowflake/cli/_plugins/nativeapp/version/commands.py +32 -5
  35. snowflake/cli/_plugins/notebook/commands.py +55 -2
  36. snowflake/cli/_plugins/notebook/exceptions.py +1 -1
  37. snowflake/cli/_plugins/notebook/manager.py +7 -5
  38. snowflake/cli/_plugins/notebook/notebook_entity.py +120 -0
  39. snowflake/cli/_plugins/notebook/notebook_entity_model.py +42 -0
  40. snowflake/cli/_plugins/notebook/notebook_project_paths.py +15 -0
  41. snowflake/cli/_plugins/notebook/types.py +3 -0
  42. snowflake/cli/_plugins/snowpark/commands.py +48 -30
  43. snowflake/cli/_plugins/snowpark/common.py +47 -2
  44. snowflake/cli/_plugins/snowpark/snowpark_entity.py +247 -4
  45. snowflake/cli/_plugins/snowpark/snowpark_entity_model.py +18 -30
  46. snowflake/cli/_plugins/snowpark/snowpark_project_paths.py +156 -23
  47. snowflake/cli/_plugins/snowpark/zipper.py +33 -1
  48. snowflake/cli/_plugins/spcs/common.py +129 -0
  49. snowflake/cli/_plugins/spcs/services/commands.py +131 -14
  50. snowflake/cli/_plugins/spcs/services/manager.py +169 -1
  51. snowflake/cli/_plugins/stage/commands.py +2 -1
  52. snowflake/cli/_plugins/stage/diff.py +60 -39
  53. snowflake/cli/_plugins/stage/manager.py +34 -13
  54. snowflake/cli/_plugins/stage/utils.py +1 -1
  55. snowflake/cli/_plugins/streamlit/commands.py +10 -1
  56. snowflake/cli/_plugins/streamlit/manager.py +70 -22
  57. snowflake/cli/_plugins/streamlit/streamlit_entity.py +131 -1
  58. snowflake/cli/_plugins/streamlit/streamlit_entity_model.py +14 -24
  59. snowflake/cli/_plugins/streamlit/streamlit_project_paths.py +30 -0
  60. snowflake/cli/_plugins/workspace/commands.py +6 -5
  61. snowflake/cli/_plugins/workspace/manager.py +9 -5
  62. snowflake/cli/api/artifacts/__init__.py +13 -0
  63. snowflake/cli/api/artifacts/bundle_map.py +500 -0
  64. snowflake/cli/api/artifacts/common.py +78 -0
  65. snowflake/cli/api/artifacts/utils.py +82 -0
  66. snowflake/cli/api/cli_global_context.py +36 -2
  67. snowflake/cli/api/commands/flags.py +10 -4
  68. snowflake/cli/api/commands/utils.py +28 -2
  69. snowflake/cli/api/config.py +6 -2
  70. snowflake/cli/api/connections.py +12 -1
  71. snowflake/cli/api/constants.py +10 -1
  72. snowflake/cli/api/entities/common.py +81 -14
  73. snowflake/cli/api/entities/resolver.py +160 -0
  74. snowflake/cli/api/entities/utils.py +65 -23
  75. snowflake/cli/api/errno.py +63 -3
  76. snowflake/cli/api/feature_flags.py +19 -4
  77. snowflake/cli/api/metrics.py +21 -27
  78. snowflake/cli/api/project/definition_conversion.py +4 -4
  79. snowflake/cli/api/project/project_paths.py +28 -0
  80. snowflake/cli/api/project/schemas/entities/common.py +130 -1
  81. snowflake/cli/api/project/schemas/entities/entities.py +4 -0
  82. snowflake/cli/api/project/schemas/project_definition.py +54 -6
  83. snowflake/cli/api/project/schemas/updatable_model.py +2 -2
  84. snowflake/cli/api/project/schemas/v1/native_app/native_app.py +5 -7
  85. snowflake/cli/api/project/schemas/v1/streamlit/streamlit.py +1 -1
  86. snowflake/cli/api/project/util.py +45 -0
  87. snowflake/cli/api/secure_path.py +6 -0
  88. snowflake/cli/api/sql_execution.py +5 -1
  89. snowflake/cli/api/stage_path.py +7 -2
  90. snowflake/cli/api/utils/graph.py +3 -0
  91. snowflake/cli/api/utils/path_utils.py +24 -0
  92. {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.4.1.dist-info}/METADATA +14 -15
  93. {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.4.1.dist-info}/RECORD +96 -82
  94. {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.4.1.dist-info}/WHEEL +1 -1
  95. snowflake/cli/api/project/schemas/v1/native_app/path_mapping.py +0 -65
  96. {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.4.1.dist-info}/entry_points.txt +0 -0
  97. {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -19,13 +19,14 @@ from dataclasses import dataclass, field
19
19
  from pathlib import Path, PurePosixPath
20
20
  from typing import Collection, Dict, List, Optional, Tuple
21
21
 
22
- from snowflake.cli._plugins.nativeapp.artifacts import BundleMap
22
+ from snowflake.cli.api.artifacts.bundle_map import BundleMap
23
23
  from snowflake.cli.api.exceptions import (
24
24
  SnowflakeSQLExecutionError,
25
25
  )
26
+ from snowflake.cli.api.project.util import unquote_identifier
26
27
  from snowflake.connector.cursor import DictCursor
27
28
 
28
- from .manager import StageManager
29
+ from .manager import StageManager, StagePathParts
29
30
  from .md5 import UnknownMD5FormatError, file_matches_md5sum
30
31
 
31
32
  log = logging.getLogger(__name__)
@@ -83,18 +84,31 @@ def enumerate_files(path: Path) -> List[Path]:
83
84
  return paths
84
85
 
85
86
 
86
- def strip_stage_name(path: str) -> StagePathType:
87
- """Returns the given stage path without the stage name as the first part."""
88
- return StagePathType(*path.split("/")[1:])
89
-
90
-
91
- def build_md5_map(list_stage_cursor: DictCursor) -> Dict[StagePathType, Optional[str]]:
87
+ def relative_to_stage_path(path: str, stage_path: StagePathParts) -> StagePathType:
88
+ """
89
+ @param path: file path on the stage.
90
+ @param stage_path: stage path object.
91
+ @return: path of the file relative to the stage and subdirectory
92
92
  """
93
- Returns a mapping of relative stage paths to their md5sums.
93
+ # path is returned from a SQL call so it's unquoted. Unquote stage_path identifiers to match.
94
+ # Stage is always returned in lower-case from ls SQL request
95
+ stage_name = unquote_identifier(stage_path.stage_name).lower()
96
+ stage_subdirectory = stage_path.directory
97
+ path_wo_stage_name = path.removeprefix(stage_name).lstrip("/")
98
+ relative_path = path_wo_stage_name.removeprefix(stage_subdirectory).lstrip("/")
99
+ return StagePathType(relative_path)
100
+
101
+
102
+ def build_md5_map(
103
+ list_stage_cursor: DictCursor, stage_path: StagePathParts
104
+ ) -> Dict[StagePathType, Optional[str]]:
94
105
  """
106
+ Returns a mapping of file paths to their md5sums. File paths are relative to the stage and subdirectory.
107
+ """
108
+ all_files = list_stage_cursor.fetchall()
95
109
  return {
96
- strip_stage_name(file["name"]): file["md5"]
97
- for file in list_stage_cursor.fetchall()
110
+ relative_to_stage_path(file["name"], stage_path): file["md5"]
111
+ for file in all_files
98
112
  }
99
113
 
100
114
 
@@ -115,50 +129,50 @@ def preserve_from_diff(
115
129
  return preserved_diff
116
130
 
117
131
 
118
- def compute_stage_diff(
119
- local_root: Path,
120
- stage_fqn: str,
121
- ) -> DiffResult:
132
+ def compute_stage_diff(local_root: Path, stage_path: StagePathParts) -> DiffResult:
122
133
  """
123
- Diffs the files in a stage with a local folder.
134
+ Diffs the files in the local_root with files in the stage path that is stage_path's full_path.
124
135
  """
125
136
  stage_manager = StageManager()
126
137
  local_files = enumerate_files(local_root)
127
- remote_md5 = build_md5_map(stage_manager.list_files(stage_fqn))
138
+ remote_files = stage_manager.list_files(stage_path.full_path)
139
+
140
+ # Create a mapping from remote_file path to file's md5sum. Path is relative to stage_name/directory.
141
+ remote_md5 = build_md5_map(remote_files, stage_path)
128
142
 
129
143
  result: DiffResult = DiffResult()
130
144
 
131
145
  for local_file in local_files:
132
146
  relpath = local_file.relative_to(local_root)
133
- stage_path = to_stage_path(relpath)
134
- if stage_path not in remote_md5:
147
+ rel_stage_path = to_stage_path(relpath)
148
+ if rel_stage_path not in remote_md5:
135
149
  # doesn't exist on the stage
136
- result.only_local.append(stage_path)
150
+ result.only_local.append(rel_stage_path)
137
151
  else:
138
152
  # N.B. file size on stage is not always accurate, so cannot fail fast
139
153
  try:
140
- if file_matches_md5sum(local_file, remote_md5[stage_path]):
154
+ if file_matches_md5sum(local_file, remote_md5[rel_stage_path]):
141
155
  # We are assuming that we will not get accidental collisions here due to the
142
156
  # large space of the md5sum (32 * 4 = 128 bits means 1-in-9-trillion chance)
143
157
  # combined with the fact that the file name + path must also match elsewhere.
144
- result.identical.append(stage_path)
158
+ result.identical.append(rel_stage_path)
145
159
  else:
146
160
  # either the file has changed, or we can't tell if it has
147
- result.different.append(stage_path)
161
+ result.different.append(rel_stage_path)
148
162
  except UnknownMD5FormatError:
149
163
  log.warning(
150
164
  "Could not compare md5 for %s, assuming file has changed",
151
165
  local_file,
152
166
  exc_info=True,
153
167
  )
154
- result.different.append(stage_path)
168
+ result.different.append(rel_stage_path)
155
169
 
156
170
  # mark this file as seen
157
- del remote_md5[stage_path]
171
+ del remote_md5[rel_stage_path]
158
172
 
159
173
  # every entry here is a file we never saw locally
160
- for stage_path in remote_md5.keys():
161
- result.only_on_stage.append(stage_path)
174
+ for rel_stage_path in remote_md5.keys():
175
+ result.only_on_stage.append(rel_stage_path)
162
176
 
163
177
  return result
164
178
 
@@ -185,7 +199,7 @@ def to_local_path(stage_path: StagePathType) -> Path:
185
199
 
186
200
  def delete_only_on_stage_files(
187
201
  stage_manager: StageManager,
188
- stage_fqn: str,
202
+ stage_root: str,
189
203
  only_on_stage: List[StagePathType],
190
204
  role: Optional[str] = None,
191
205
  ):
@@ -193,12 +207,12 @@ def delete_only_on_stage_files(
193
207
  Deletes all files from a Snowflake stage according to the input list of filenames, using a custom role.
194
208
  """
195
209
  for _stage_path in only_on_stage:
196
- stage_manager.remove(stage_name=stage_fqn, path=str(_stage_path), role=role)
210
+ stage_manager.remove(stage_name=stage_root, path=str(_stage_path), role=role)
197
211
 
198
212
 
199
213
  def put_files_on_stage(
200
214
  stage_manager: StageManager,
201
- stage_fqn: str,
215
+ stage_root: str,
202
216
  deploy_root_path: Path,
203
217
  stage_paths: List[StagePathType],
204
218
  role: Optional[str] = None,
@@ -210,7 +224,7 @@ def put_files_on_stage(
210
224
  for _stage_path in stage_paths:
211
225
  stage_sub_path = get_stage_subpath(_stage_path)
212
226
  full_stage_path = (
213
- f"{stage_fqn}/{stage_sub_path}" if stage_sub_path else stage_fqn
227
+ f"{stage_root}/{stage_sub_path}" if stage_sub_path else stage_root
214
228
  )
215
229
  stage_manager.put(
216
230
  local_path=deploy_root_path / to_local_path(_stage_path),
@@ -221,7 +235,10 @@ def put_files_on_stage(
221
235
 
222
236
 
223
237
  def sync_local_diff_with_stage(
224
- role: str | None, deploy_root_path: Path, diff_result: DiffResult, stage_fqn: str
238
+ role: str | None,
239
+ deploy_root_path: Path,
240
+ diff_result: DiffResult,
241
+ stage_full_path: str,
225
242
  ):
226
243
  """
227
244
  Syncs a given local directory's contents with a Snowflake stage, including removing old files, and re-uploading modified and new files.
@@ -234,18 +251,22 @@ def sync_local_diff_with_stage(
234
251
 
235
252
  try:
236
253
  delete_only_on_stage_files(
237
- stage_manager, stage_fqn, diff_result.only_on_stage, role
254
+ stage_manager, stage_full_path, diff_result.only_on_stage, role
238
255
  )
239
256
  put_files_on_stage(
240
- stage_manager,
241
- stage_fqn,
242
- deploy_root_path,
243
- diff_result.different,
244
- role,
257
+ stage_manager=stage_manager,
258
+ stage_root=stage_full_path,
259
+ deploy_root_path=deploy_root_path,
260
+ stage_paths=diff_result.different,
261
+ role=role,
245
262
  overwrite=True,
246
263
  )
247
264
  put_files_on_stage(
248
- stage_manager, stage_fqn, deploy_root_path, diff_result.only_local, role
265
+ stage_manager=stage_manager,
266
+ stage_root=stage_full_path,
267
+ deploy_root_path=deploy_root_path,
268
+ stage_paths=diff_result.only_local,
269
+ role=role,
249
270
  )
250
271
  except Exception as err:
251
272
  # Could be ProgrammingError or IntegrityError from SnowflakeCursor
@@ -41,11 +41,11 @@ from snowflake.cli.api.commands.utils import parse_key_value_variables
41
41
  from snowflake.cli.api.console import cli_console
42
42
  from snowflake.cli.api.constants import PYTHON_3_12
43
43
  from snowflake.cli.api.identifiers import FQN
44
- from snowflake.cli.api.project.util import to_string_literal
44
+ from snowflake.cli.api.project.util import VALID_IDENTIFIER_REGEX, to_string_literal
45
45
  from snowflake.cli.api.secure_path import SecurePath
46
46
  from snowflake.cli.api.sql_execution import SqlExecutionMixin
47
47
  from snowflake.cli.api.stage_path import StagePath
48
- from snowflake.cli.api.utils.path_utils import path_resolver
48
+ from snowflake.cli.api.utils.path_utils import path_resolver, resolve_without_follow
49
49
  from snowflake.connector import DictCursor, ProgrammingError
50
50
  from snowflake.connector.cursor import SnowflakeCursor
51
51
 
@@ -65,6 +65,7 @@ EXECUTE_SUPPORTED_FILES_FORMATS = (
65
65
 
66
66
  # Replace magic numbers with constants
67
67
  OMIT_FIRST = slice(1, None)
68
+ STAGE_PATH_REGEX = rf"(?P<prefix>@)?(?:(?P<first_qualifier>{VALID_IDENTIFIER_REGEX})\.)?(?:(?P<second_qualifier>{VALID_IDENTIFIER_REGEX})\.)?(?P<name>{VALID_IDENTIFIER_REGEX})/?(?P<directory>([^/]*/?)*)?"
68
69
 
69
70
 
70
71
  @dataclass
@@ -86,6 +87,10 @@ class StagePathParts:
86
87
  def full_path(self) -> str:
87
88
  raise NotImplementedError
88
89
 
90
+ @property
91
+ def schema(self) -> str | None:
92
+ raise NotImplementedError
93
+
89
94
  def replace_stage_prefix(self, file_path: str) -> str:
90
95
  raise NotImplementedError
91
96
 
@@ -128,22 +133,38 @@ class DefaultStagePathParts(StagePathParts):
128
133
  """
129
134
 
130
135
  def __init__(self, stage_path: str):
131
- self.directory = self.get_directory(stage_path)
132
- self.stage = StageManager.get_stage_from_path(stage_path)
133
- stage_name = self.stage.split(".")[-1]
136
+ match = re.fullmatch(STAGE_PATH_REGEX, stage_path)
137
+ if match is None:
138
+ raise ClickException("Invalid stage path")
139
+ self.directory = match.group("directory")
140
+ self._schema = match.group("second_qualifier") or match.group("first_qualifier")
141
+ self.stage = stage_path.removesuffix(self.directory).rstrip("/")
142
+
143
+ stage_name = FQN.from_stage(self.stage).name
134
144
  stage_name = (
135
145
  stage_name[OMIT_FIRST] if stage_name.startswith("@") else stage_name
136
146
  )
137
147
  self.stage_name = stage_name
138
148
  self.is_directory = True if stage_path.endswith("/") else False
139
149
 
150
+ @classmethod
151
+ def from_fqn(
152
+ cls, stage_fqn: str, subdir: str | None = None
153
+ ) -> DefaultStagePathParts:
154
+ full_path = f"{stage_fqn}/{subdir}" if subdir else stage_fqn
155
+ return cls(full_path)
156
+
140
157
  @property
141
158
  def path(self) -> str:
142
- return f"{self.stage_name.rstrip('/')}/{self.directory}"
159
+ return f"{self.stage_name.rstrip('/')}/{self.directory}".rstrip("/")
143
160
 
144
161
  @property
145
162
  def full_path(self) -> str:
146
- return f"{self.stage.rstrip('/')}/{self.directory}"
163
+ return f"{self.stage.rstrip('/')}/{self.directory}".rstrip("/")
164
+
165
+ @property
166
+ def schema(self) -> str | None:
167
+ return self._schema
147
168
 
148
169
  def replace_stage_prefix(self, file_path: str) -> str:
149
170
  stage = Path(self.stage).parts[0]
@@ -193,7 +214,7 @@ class UserStagePathParts(StagePathParts):
193
214
 
194
215
  @property
195
216
  def full_path(self) -> str:
196
- return f"{self.stage}/{self.directory}"
217
+ return f"{self.stage}/{self.directory}".rstrip("/")
197
218
 
198
219
  def replace_stage_prefix(self, file_path: str) -> str:
199
220
  if Path(file_path).parts[0] == self.stage_name:
@@ -337,7 +358,6 @@ class StageManager(SqlExecutionMixin):
337
358
 
338
359
  @staticmethod
339
360
  def _symlink_or_copy(source_root: Path, source_file_or_dir: Path, dest_dir: Path):
340
- from snowflake.cli._plugins.nativeapp.artifacts import resolve_without_follow
341
361
 
342
362
  absolute_src = resolve_without_follow(source_file_or_dir)
343
363
  dest_path = dest_dir / source_file_or_dir.relative_to(source_root)
@@ -516,7 +536,7 @@ class StageManager(SqlExecutionMixin):
516
536
  stage_path_parts.get_standard_stage_path()
517
537
  )
518
538
  else:
519
- stage_path_parts = self._stage_path_part_factory(stage_path_str)
539
+ stage_path_parts = self.stage_path_parts_from_str(stage_path_str)
520
540
  stage_path = self.build_path(stage_path_str)
521
541
 
522
542
  all_files_list = self._get_files_list_from_stage(stage_path.root_path())
@@ -584,12 +604,12 @@ class StageManager(SqlExecutionMixin):
584
604
  sm = StageManager()
585
605
 
586
606
  # Rewrite stage paths to temporary stage paths. Git paths become stage paths
587
- original_path_parts = self._stage_path_part_factory(stage_path) # noqa: SLF001
607
+ original_path_parts = self.stage_path_parts_from_str(stage_path) # noqa: SLF001
588
608
 
589
609
  tmp_stage_name = f"snowflake_cli_tmp_stage_{int(time.time())}"
590
610
  tmp_stage_fqn = FQN.from_stage(tmp_stage_name).using_connection(conn=self._conn)
591
611
  tmp_stage = tmp_stage_fqn.identifier
592
- stage_path_parts = sm._stage_path_part_factory( # noqa: SLF001
612
+ stage_path_parts = sm.stage_path_parts_from_str( # noqa: SLF001
593
613
  tmp_stage + "/" + original_path_parts.directory
594
614
  )
595
615
 
@@ -693,7 +713,8 @@ class StageManager(SqlExecutionMixin):
693
713
  return StageManager._error_result(file=original_file, msg=e.msg)
694
714
 
695
715
  @staticmethod
696
- def _stage_path_part_factory(stage_path: str) -> StagePathParts:
716
+ def stage_path_parts_from_str(stage_path: str) -> StagePathParts:
717
+ """Create StagePathParts object from stage path string."""
697
718
  stage_path = StageManager.get_standard_stage_prefix(stage_path)
698
719
  if stage_path.startswith(USER_STAGE_PREFIX):
699
720
  return UserStagePathParts(stage_path)
@@ -1,11 +1,11 @@
1
1
  from typing import Optional
2
2
 
3
- from snowflake.cli._plugins.nativeapp.artifacts import BundleMap
4
3
  from snowflake.cli._plugins.stage.diff import (
5
4
  DiffResult,
6
5
  _to_diff_line,
7
6
  _to_src_dest_pair,
8
7
  )
8
+ from snowflake.cli.api.artifacts.bundle_map import BundleMap
9
9
  from snowflake.cli.api.console import cli_console as cc
10
10
 
11
11
 
@@ -29,6 +29,9 @@ from snowflake.cli._plugins.streamlit.manager import StreamlitManager
29
29
  from snowflake.cli._plugins.streamlit.streamlit_entity_model import (
30
30
  StreamlitEntityModel,
31
31
  )
32
+ from snowflake.cli._plugins.streamlit.streamlit_project_paths import (
33
+ StreamlitProjectPaths,
34
+ )
32
35
  from snowflake.cli.api.cli_global_context import get_cli_context
33
36
  from snowflake.cli.api.commands.decorators import (
34
37
  with_experimental_behaviour,
@@ -156,6 +159,8 @@ def streamlit_deploy(
156
159
  entity_type="streamlit"
157
160
  )
158
161
 
162
+ streamlit_project_paths = StreamlitProjectPaths(cli_context.project_root)
163
+
159
164
  if not streamlits:
160
165
  raise NoProjectDefinitionError(
161
166
  project_type="streamlit", project_root=cli_context.project_root
@@ -174,7 +179,11 @@ def streamlit_deploy(
174
179
 
175
180
  # Get first streamlit
176
181
  streamlit: StreamlitEntityModel = streamlits[entity_id]
177
- url = StreamlitManager().deploy(streamlit=streamlit, replace=replace)
182
+ url = StreamlitManager().deploy(
183
+ streamlit=streamlit,
184
+ streamlit_project_paths=streamlit_project_paths,
185
+ replace=replace,
186
+ )
178
187
 
179
188
  if open_:
180
189
  typer.launch(url)
@@ -15,7 +15,7 @@
15
15
  from __future__ import annotations
16
16
 
17
17
  import logging
18
- from pathlib import Path
18
+ from pathlib import PurePosixPath
19
19
  from typing import List, Optional
20
20
 
21
21
  from click import ClickException
@@ -29,12 +29,18 @@ from snowflake.cli._plugins.stage.manager import StageManager
29
29
  from snowflake.cli._plugins.streamlit.streamlit_entity_model import (
30
30
  StreamlitEntityModel,
31
31
  )
32
+ from snowflake.cli._plugins.streamlit.streamlit_project_paths import (
33
+ StreamlitProjectPaths,
34
+ )
35
+ from snowflake.cli.api.artifacts.bundle_map import BundleMap
36
+ from snowflake.cli.api.artifacts.utils import symlink_or_copy
32
37
  from snowflake.cli.api.commands.experimental_behaviour import (
33
38
  experimental_behaviour_enabled,
34
39
  )
35
40
  from snowflake.cli.api.console import cli_console
36
41
  from snowflake.cli.api.feature_flags import FeatureFlag
37
42
  from snowflake.cli.api.identifiers import FQN
43
+ from snowflake.cli.api.project.schemas.entities.common import PathMapping
38
44
  from snowflake.cli.api.sql_execution import SqlExecutionMixin
39
45
  from snowflake.connector.cursor import SnowflakeCursor
40
46
  from snowflake.connector.errors import ProgrammingError
@@ -54,26 +60,45 @@ class StreamlitManager(SqlExecutionMixin):
54
60
 
55
61
  def _put_streamlit_files(
56
62
  self,
57
- root_location: str,
58
- artifacts: Optional[List[Path]] = None,
63
+ streamlit_project_paths: StreamlitProjectPaths,
64
+ stage_root: str,
65
+ artifacts: Optional[List[PathMapping]] = None,
59
66
  ):
60
- cli_console.step(f"Deploying files to {root_location}")
67
+ cli_console.step(f"Deploying files to {stage_root}")
61
68
  if not artifacts:
62
69
  return
63
70
  stage_manager = StageManager()
64
- for file in artifacts:
65
- if file.is_dir():
66
- if not any(file.iterdir()):
67
- cli_console.warning(f"Skipping empty directory: {file}")
68
- continue
71
+ # We treat the bundle root as deploy root
72
+ bundle_map = BundleMap(
73
+ project_root=streamlit_project_paths.project_root,
74
+ deploy_root=streamlit_project_paths.bundle_root,
75
+ )
76
+ for artifact in artifacts:
77
+ bundle_map.add(PathMapping(src=str(artifact.src), dest=artifact.dest))
78
+
79
+ # Clean up bundle root
80
+ streamlit_project_paths.remove_up_bundle_root()
69
81
 
82
+ for (absolute_src, absolute_dest) in bundle_map.all_mappings(
83
+ absolute=True, expand_directories=True
84
+ ):
85
+ if absolute_src.is_file():
86
+ # We treat the bundle/streamlit root as deploy root
87
+ symlink_or_copy(
88
+ absolute_src,
89
+ absolute_dest,
90
+ deploy_root=streamlit_project_paths.bundle_root,
91
+ )
92
+ # Temporary solution, will be replaced with diff
93
+ stage_path = (
94
+ PurePosixPath(absolute_dest)
95
+ .relative_to(streamlit_project_paths.bundle_root)
96
+ .parent
97
+ )
98
+ full_stage_path = f"{stage_root}/{stage_path}".rstrip("/")
70
99
  stage_manager.put(
71
- f"{file.joinpath('*')}", f"{root_location}/{file}", 4, True
100
+ local_path=absolute_dest, stage_path=full_stage_path, overwrite=True
72
101
  )
73
- elif len(file.parts) > 1:
74
- stage_manager.put(file, f"{root_location}/{file.parent}", 4, True)
75
- else:
76
- stage_manager.put(file, root_location, 4, True)
77
102
 
78
103
  def _create_streamlit(
79
104
  self,
@@ -104,8 +129,15 @@ class StreamlitManager(SqlExecutionMixin):
104
129
  query.append(f"MAIN_FILE = '{streamlit.main_file}'")
105
130
  if streamlit.imports:
106
131
  query.append(streamlit.get_imports_sql())
107
- if streamlit.query_warehouse:
132
+
133
+ if not streamlit.query_warehouse:
134
+ cli_console.warning(
135
+ "[Deprecation] In next major version we will remove default query_warehouse='streamlit'."
136
+ )
137
+ query.append(f"QUERY_WAREHOUSE = 'streamlit'")
138
+ else:
108
139
  query.append(f"QUERY_WAREHOUSE = {streamlit.query_warehouse}")
140
+
109
141
  if streamlit.title:
110
142
  query.append(f"TITLE = '{streamlit.title}'")
111
143
 
@@ -120,7 +152,12 @@ class StreamlitManager(SqlExecutionMixin):
120
152
 
121
153
  self.execute_query("\n".join(query))
122
154
 
123
- def deploy(self, streamlit: StreamlitEntityModel, replace: bool = False):
155
+ def deploy(
156
+ self,
157
+ streamlit: StreamlitEntityModel,
158
+ streamlit_project_paths: StreamlitProjectPaths,
159
+ replace: bool = False,
160
+ ):
124
161
  streamlit_id = streamlit.fqn.using_connection(self._conn)
125
162
  if (
126
163
  ObjectManager().object_exists(object_type="streamlit", fqn=streamlit_id)
@@ -172,12 +209,13 @@ class StreamlitManager(SqlExecutionMixin):
172
209
  embedded_stage_name = f"snow://streamlit/{stage_path}"
173
210
  if use_versioned_stage:
174
211
  # "LIVE" is the only supported version for now, but this may change later.
175
- root_location = f"{embedded_stage_name}/versions/live"
212
+ stage_root = f"{embedded_stage_name}/versions/live"
176
213
  else:
177
- root_location = f"{embedded_stage_name}/default_checkout"
214
+ stage_root = f"{embedded_stage_name}/default_checkout"
178
215
 
179
216
  self._put_streamlit_files(
180
- root_location,
217
+ streamlit_project_paths,
218
+ stage_root,
181
219
  streamlit.artifacts,
182
220
  )
183
221
  else:
@@ -194,21 +232,31 @@ class StreamlitManager(SqlExecutionMixin):
194
232
  cli_console.step(f"Creating {stage_name} stage")
195
233
  stage_manager.create(fqn=stage_name)
196
234
 
197
- root_location = stage_manager.get_standard_stage_prefix(
235
+ stage_root = stage_manager.get_standard_stage_prefix(
198
236
  f"{stage_name}/{streamlit_name_for_root_location}"
199
237
  )
200
238
 
201
- self._put_streamlit_files(root_location, streamlit.artifacts)
239
+ self._put_streamlit_files(
240
+ streamlit_project_paths, stage_root, streamlit.artifacts
241
+ )
202
242
 
203
243
  self._create_streamlit(
204
244
  streamlit=streamlit,
205
245
  replace=replace,
206
- from_stage_name=root_location,
246
+ from_stage_name=stage_root,
207
247
  experimental=False,
208
248
  )
209
249
 
250
+ self.grant_privileges(streamlit)
251
+
210
252
  return self.get_url(streamlit_name=streamlit_id)
211
253
 
254
+ def grant_privileges(self, entity_model: StreamlitEntityModel):
255
+ if not entity_model.grants:
256
+ return
257
+ for grant in entity_model.grants:
258
+ self.execute_query(grant.get_grant_sql(entity_model))
259
+
212
260
  def get_url(self, streamlit_name: FQN) -> str:
213
261
  try:
214
262
  fqn = streamlit_name.using_connection(self._conn)
@@ -1,7 +1,18 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+
4
+ from click import ClickException
5
+ from snowflake.cli._plugins.connection.util import make_snowsight_url
6
+ from snowflake.cli._plugins.nativeapp.artifacts import build_bundle
7
+ from snowflake.cli._plugins.nativeapp.feature_flags import FeatureFlag
1
8
  from snowflake.cli._plugins.streamlit.streamlit_entity_model import (
2
9
  StreamlitEntityModel,
3
10
  )
11
+ from snowflake.cli._plugins.workspace.context import ActionContext
4
12
  from snowflake.cli.api.entities.common import EntityBase
13
+ from snowflake.cli.api.project.project_paths import bundle_root
14
+ from snowflake.cli.api.project.schemas.entities.common import PathMapping
15
+ from snowflake.connector.cursor import SnowflakeCursor
5
16
 
6
17
 
7
18
  class StreamlitEntity(EntityBase[StreamlitEntityModel]):
@@ -9,4 +20,123 @@ class StreamlitEntity(EntityBase[StreamlitEntityModel]):
9
20
  A Streamlit app.
10
21
  """
11
22
 
12
- pass
23
+ def __init__(self, *args, **kwargs):
24
+ if not FeatureFlag.ENABLE_NATIVE_APP_CHILDREN.is_enabled():
25
+ raise NotImplementedError("Streamlit entity is not implemented yet")
26
+ super().__init__(*args, **kwargs)
27
+
28
+ @property
29
+ def root(self):
30
+ return self._workspace_ctx.project_root
31
+
32
+ @property
33
+ def artifacts(self):
34
+ return self._entity_model.artifacts
35
+
36
+ def action_bundle(self, action_ctx: ActionContext, *args, **kwargs):
37
+ return self.bundle()
38
+
39
+ def action_deploy(self, action_ctx: ActionContext, *args, **kwargs):
40
+ # After adding bundle map- we should use it's mapping here
41
+ # To copy artifacts to destination on stage.
42
+
43
+ return self.deploy()
44
+
45
+ def action_drop(self, action_ctx: ActionContext, *args, **kwargs):
46
+ return self._execute_query(self.get_drop_sql())
47
+
48
+ def action_execute(
49
+ self, action_ctx: ActionContext, *args, **kwargs
50
+ ) -> SnowflakeCursor:
51
+ return self._execute_query(self.get_execute_sql())
52
+
53
+ def action_get_url(
54
+ self, action_ctx: ActionContext, *args, **kwargs
55
+ ): # maybe this should be a property
56
+ name = self._entity_model.fqn.using_connection(self._conn)
57
+ return make_snowsight_url(
58
+ self._conn, f"/#/streamlit-apps/{name.url_identifier}"
59
+ )
60
+
61
+ def bundle(self, output_dir: Optional[Path] = None):
62
+ build_bundle(
63
+ self.root,
64
+ output_dir or bundle_root(self.root, "streamlit"),
65
+ [
66
+ PathMapping(
67
+ src=artifact.src, dest=artifact.dest, processors=artifact.processors
68
+ )
69
+ for artifact in self._entity_model.artifacts
70
+ ],
71
+ )
72
+
73
+ def deploy(self, *args, **kwargs):
74
+ return self._execute_query(self.get_deploy_sql())
75
+
76
+ def action_share(
77
+ self, action_ctx: ActionContext, to_role: str, *args, **kwargs
78
+ ) -> SnowflakeCursor:
79
+ return self._execute_query(self.get_share_sql(to_role))
80
+
81
+ def get_deploy_sql(
82
+ self,
83
+ if_not_exists: bool = False,
84
+ replace: bool = False,
85
+ from_stage_name: Optional[str] = None,
86
+ artifacts_dir: Optional[Path] = None,
87
+ schema: Optional[str] = None,
88
+ *args,
89
+ **kwargs,
90
+ ):
91
+ if replace and if_not_exists:
92
+ raise ClickException("Cannot specify both replace and if_not_exists")
93
+
94
+ if replace:
95
+ query = "CREATE OR REPLACE "
96
+ elif if_not_exists:
97
+ query = "CREATE IF NOT EXISTS "
98
+ else:
99
+ query = "CREATE "
100
+
101
+ schema_to_use = schema or self._entity_model.fqn.schema
102
+ query += f"STREAMLIT {self._entity_model.fqn.set_schema(schema_to_use).sql_identifier}"
103
+
104
+ if from_stage_name:
105
+ query += f"\nROOT_LOCATION = '{from_stage_name}'"
106
+ elif artifacts_dir:
107
+ query += f"\nFROM '{artifacts_dir}'"
108
+
109
+ query += f"\nMAIN_FILE = '{self._entity_model.main_file}'"
110
+
111
+ if self.model.imports:
112
+ query += "\n" + self.model.get_imports_sql()
113
+
114
+ if self.model.query_warehouse:
115
+ query += f"\nQUERY_WAREHOUSE = '{self.model.query_warehouse}'"
116
+
117
+ if self.model.title:
118
+ query += f"\nTITLE = '{self.model.title}'"
119
+
120
+ if self.model.comment:
121
+ query += f"\nCOMMENT = '{self.model.comment}'"
122
+
123
+ if self.model.external_access_integrations:
124
+ query += "\n" + self.model.get_external_access_integrations_sql()
125
+
126
+ if self.model.secrets:
127
+ query += "\n" + self.model.get_secrets_sql()
128
+
129
+ return query + ";"
130
+
131
+ def get_share_sql(self, to_role: str) -> str:
132
+ return f"GRANT USAGE ON STREAMLIT {self.model.fqn.sql_identifier} TO ROLE {to_role};"
133
+
134
+ def get_execute_sql(self):
135
+ return f"EXECUTE STREAMLIT {self._entity_model.fqn}();"
136
+
137
+ def get_usage_grant_sql(self, app_role: str, schema: Optional[str] = None) -> str:
138
+ entity_id = self.entity_id
139
+ streamlit_name = f"{schema}.{entity_id}" if schema else entity_id
140
+ return (
141
+ f"GRANT USAGE ON STREAMLIT {streamlit_name} TO APPLICATION ROLE {app_role};"
142
+ )