Skip to content

Commit c5f595c

Browse files
Snow 1922856 refactor and unify uploading artifact to stage (#2133)
* notebooks * fix stage sync utils * Fix notebooks * refactor; add Streamlit * refactor notebook * Fix streamlit tests * Add support for snow:// prefix in StagePathParts * Fix notebook tests * snowpark * test streamlit * snowpark test * update release notes * Review fixes * fix dcm tests * fix dcm tests
1 parent 2e13ad6 commit c5f595c

File tree

24 files changed

+422
-280
lines changed

24 files changed

+422
-280
lines changed

RELEASE-NOTES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
## Deprecations
2020

2121
## New additions
22+
* Added `--prune` flag to `deploy` commands, which removes files that exist in the stage,
23+
but not in the local filesystem.
2224

2325
## Fixes and improvements
2426

src/snowflake/cli/_plugins/notebook/commands.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from snowflake.cli.api.cli_global_context import get_cli_context
2424
from snowflake.cli.api.commands.decorators import with_project_definition
2525
from snowflake.cli.api.commands.flags import (
26+
PruneOption,
2627
ReplaceOption,
2728
entity_argument,
2829
identifier_argument,
@@ -108,6 +109,7 @@ def deploy(
108109
help="Replace notebook object if it already exists. It only uploads new and overwrites existing files, "
109110
"but does not remove any files already on the stage.",
110111
),
112+
prune: bool = PruneOption(),
111113
**options,
112114
) -> CommandResult:
113115
"""Uploads a notebook and required files to a stage and creates a Snowflake notebook."""
@@ -132,6 +134,7 @@ def deploy(
132134
notebook.entity_id,
133135
EntityActions.DEPLOY,
134136
replace=replace,
137+
prune=prune,
135138
)
136139
return MessageResult(
137140
f"Notebook successfully deployed and available under {notebook_url}"

src/snowflake/cli/_plugins/notebook/notebook_entity.py

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@
44
from snowflake.cli._plugins.connection.util import make_snowsight_url
55
from snowflake.cli._plugins.notebook.notebook_entity_model import NotebookEntityModel
66
from snowflake.cli._plugins.notebook.notebook_project_paths import NotebookProjectPaths
7-
from snowflake.cli._plugins.stage.manager import StageManager
87
from snowflake.cli._plugins.workspace.context import ActionContext
9-
from snowflake.cli.api.artifacts.utils import bundle_artifacts
8+
from snowflake.cli.api.artifacts.upload import sync_artifacts_with_stage
109
from snowflake.cli.api.cli_global_context import get_cli_context
1110
from snowflake.cli.api.console.console import cli_console
1211
from snowflake.cli.api.entities.common import EntityBase
@@ -22,12 +21,15 @@ class NotebookEntity(EntityBase[NotebookEntityModel]):
2221
A notebook.
2322
"""
2423

24+
@property
25+
def _stage_path_from_model(self) -> str:
26+
if self.model.stage_path is None:
27+
return f"{_DEFAULT_NOTEBOOK_STAGE_NAME}/{self.fqn.name}"
28+
return self.model.stage_path
29+
2530
@functools.cached_property
2631
def _stage_path(self) -> StagePath:
27-
stage_path = self.model.stage_path
28-
if stage_path is None:
29-
stage_path = f"{_DEFAULT_NOTEBOOK_STAGE_NAME}/{self.fqn.name}"
30-
return StagePath.from_stage_str(stage_path)
32+
return StagePath.from_stage_str(self._stage_path_from_model)
3133

3234
@functools.cached_property
3335
def _project_paths(self):
@@ -41,26 +43,6 @@ def _object_exists(self) -> bool:
4143
except ProgrammingError:
4244
return False
4345

44-
def _upload_artifacts(self):
45-
stage_fqn = self._stage_path.stage_fqn
46-
stage_manager = StageManager()
47-
cli_console.step(f"Creating stage {stage_fqn} if not exists")
48-
stage_manager.create(fqn=stage_fqn)
49-
50-
cli_console.step("Uploading artifacts")
51-
52-
# creating bundle map to handle glob patterns logic
53-
bundle_map = bundle_artifacts(self._project_paths, self.model.artifacts)
54-
for absolute_src, absolute_dest in bundle_map.all_mappings(
55-
absolute=True, expand_directories=True
56-
):
57-
artifact_stage_path = self._stage_path / (
58-
absolute_dest.relative_to(self._project_paths.bundle_root).parent
59-
)
60-
stage_manager.put(
61-
local_path=absolute_src, stage_path=artifact_stage_path, overwrite=True
62-
)
63-
6446
def get_create_sql(self, replace: bool) -> str:
6547
main_file_stage_path = self._stage_path / (
6648
self.model.notebook_file.absolute().relative_to(
@@ -99,6 +81,7 @@ def action_deploy(
9981
self,
10082
action_ctx: ActionContext,
10183
replace: bool,
84+
prune: bool,
10285
*args,
10386
**kwargs,
10487
) -> str:
@@ -108,7 +91,13 @@ def action_deploy(
10891
f"Notebook {self.fqn.name} already exists. Consider using --replace."
10992
)
11093
with cli_console.phase(f"Uploading artifacts to {self._stage_path}"):
111-
self._upload_artifacts()
94+
sync_artifacts_with_stage(
95+
project_paths=self._project_paths,
96+
stage_root=self._stage_path_from_model,
97+
prune=prune,
98+
artifacts=self.model.artifacts,
99+
)
100+
112101
with cli_console.phase(f"Creating notebook {self.fqn}"):
113102
return self.action_create(replace=replace)
114103

src/snowflake/cli/_plugins/project/commands.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@
2222
from snowflake.cli._plugins.project.project_entity_model import (
2323
ProjectEntityModel,
2424
)
25-
from snowflake.cli._plugins.stage.manager import StageManager
26-
from snowflake.cli.api.artifacts.upload import put_files
25+
from snowflake.cli.api.artifacts.upload import sync_artifacts_with_stage
2726
from snowflake.cli.api.cli_global_context import get_cli_context
2827
from snowflake.cli.api.commands.decorators import with_project_definition
2928
from snowflake.cli.api.commands.flags import (
@@ -118,20 +117,16 @@ def create_version(
118117

119118
# Sync state
120119
with cli_console.phase("Syncing project state"):
121-
stage_name = FQN.from_stage(project.stage)
122-
sm = StageManager()
123-
124-
cli_console.step(f"Creating stage {stage_name}")
125-
sm.create(fqn=stage_name)
126-
127-
put_files(
120+
sync_artifacts_with_stage(
128121
project_paths=ProjectPaths(project_root=cli_context.project_root),
129122
stage_root=project.stage,
130123
artifacts=project.artifacts,
131124
)
132125

133126
# Create project and version
134127
with cli_console.phase("Creating project and version"):
128+
stage_name = FQN.from_stage(project.stage)
129+
135130
pm = ProjectManager()
136131
cli_console.step(f"Creating project {project.fqn}")
137132
pm.create(project_name=project.fqn)

src/snowflake/cli/_plugins/snowpark/commands.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
)
7272
from snowflake.cli.api.commands.flags import (
7373
ForceReplaceOption,
74+
PruneOption,
7475
ReplaceOption,
7576
execution_identifier_argument,
7677
identifier_argument,
@@ -133,6 +134,9 @@ def deploy(
133134
"overwrites existing files, but does not remove any files already on the stage."
134135
),
135136
force_replace: bool = ForceReplaceOption(),
137+
prune: bool = PruneOption(
138+
help="Remove contents of the stage before uploading artifacts."
139+
),
136140
**options,
137141
) -> CommandResult:
138142
"""
@@ -174,7 +178,7 @@ def deploy(
174178
snowpark_entities=snowpark_entities,
175179
)
176180

177-
create_stages_and_upload_artifacts(stages_to_artifact_map)
181+
create_stages_and_upload_artifacts(stages_to_artifact_map, prune=prune)
178182

179183
# Create snowpark entities
180184
with cli_console.phase("Creating Snowpark entities"):
@@ -242,8 +246,17 @@ def build_artifacts_mappings(
242246
return entities_to_imports_map, stages_to_artifact_map
243247

244248

245-
def create_stages_and_upload_artifacts(stages_to_artifact_map: StageToArtifactMapping):
249+
def create_stages_and_upload_artifacts(
250+
stages_to_artifact_map: StageToArtifactMapping, prune: bool
251+
):
246252
stage_manager = StageManager()
253+
if prune:
254+
# snowflake.cli._plugins.snowpark.snowpark_project_paths.Artifact class assumes that "stage"
255+
# is a stage object, not path on stage - whole stage is managed by snowpark - it can be removed
256+
for stage in stages_to_artifact_map.keys():
257+
cli_console.step(f"Removing contents of stage {stage}")
258+
stage_manager.remove(stage, path="")
259+
247260
for stage, artifacts in stages_to_artifact_map.items():
248261
cli_console.step(f"Creating (if not exists) stage: {stage}")
249262
stage = FQN.from_stage(stage).using_context()

src/snowflake/cli/_plugins/stage/manager.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565

6666
# Replace magic numbers with constants
6767
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+
STAGE_PATH_REGEX = rf"(?P<prefix>(@|{re.escape('snow://')}))?(?:(?P<first_qualifier>{VALID_IDENTIFIER_REGEX})\.)?(?:(?P<second_qualifier>{VALID_IDENTIFIER_REGEX})\.)?(?P<name>{VALID_IDENTIFIER_REGEX})/?(?P<directory>([^/]*/?)*)?"
6969

7070

7171
@dataclass
@@ -119,15 +119,23 @@ def strip_stage_prefix(self, path: str):
119119
raise NotImplementedError
120120

121121

122+
def _strip_standard_stage_prefix(path: str) -> str:
123+
"""Removes '@' or 'snow://' prefix from given string"""
124+
for prefix in ["@", "snow://"]:
125+
if path.startswith(prefix):
126+
path = path.removeprefix(prefix)
127+
return path
128+
129+
122130
@dataclass
123131
class DefaultStagePathParts(StagePathParts):
124132
"""
125133
For path like @db.schema.stage/dir the values will be:
126134
directory = dir
127135
stage = @db.schema.stage
128136
stage_name = stage
129-
For `@stage/dir` to
130-
stage -> @stage
137+
For `snow://stage/dir` to
138+
stage -> snow://stage
131139
stage_name -> stage
132140
directory -> dir
133141
"""
@@ -138,12 +146,12 @@ def __init__(self, stage_path: str):
138146
raise ClickException("Invalid stage path")
139147
self.directory = match.group("directory")
140148
self._schema = match.group("second_qualifier") or match.group("first_qualifier")
149+
self._prefix = match.group("prefix") or "@"
141150
self.stage = stage_path.removesuffix(self.directory).rstrip("/")
142151

143152
stage_name = FQN.from_stage(self.stage).name
144-
stage_name = (
145-
stage_name[OMIT_FIRST] if stage_name.startswith("@") else stage_name
146-
)
153+
if stage_name.startswith(self._prefix):
154+
stage_name = stage_name.removeprefix(self._prefix)
147155
self.stage_name = stage_name
148156
self.is_directory = True if stage_path.endswith("/") else False
149157

@@ -167,13 +175,12 @@ def schema(self) -> str | None:
167175
return self._schema
168176

169177
def replace_stage_prefix(self, file_path: str) -> str:
170-
stage = Path(self.stage).parts[0]
178+
file_path = _strip_standard_stage_prefix(file_path)
171179
file_path_without_prefix = Path(file_path).parts[OMIT_FIRST]
172-
return f"{stage}/{'/'.join(file_path_without_prefix)}"
180+
return f"{self.stage}/{'/'.join(file_path_without_prefix)}"
173181

174182
def strip_stage_prefix(self, file_path: str) -> str:
175-
if file_path.startswith("@"):
176-
file_path = file_path[OMIT_FIRST]
183+
file_path = _strip_standard_stage_prefix(file_path)
177184
if file_path.startswith(self.stage_name):
178185
return file_path[len(self.stage_name) :]
179186
return file_path

src/snowflake/cli/_plugins/streamlit/commands.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
with_project_definition,
3838
)
3939
from snowflake.cli.api.commands.flags import (
40+
PruneOption,
4041
ReplaceOption,
4142
entity_argument,
4243
identifier_argument,
@@ -136,6 +137,7 @@ def streamlit_deploy(
136137
help="Replaces the Streamlit app if it already exists. It only uploads new and overwrites existing files, "
137138
"but does not remove any files already on the stage."
138139
),
140+
prune: bool = PruneOption(),
139141
entity_id: str = entity_argument("streamlit"),
140142
open_: bool = OpenOption,
141143
**options,
@@ -168,6 +170,7 @@ def streamlit_deploy(
168170
streamlit=streamlit,
169171
streamlit_project_paths=streamlit_project_paths,
170172
replace=replace,
173+
prune=prune,
171174
)
172175

173176
if open_:

src/snowflake/cli/_plugins/streamlit/manager.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from snowflake.cli._plugins.streamlit.streamlit_project_paths import (
3232
StreamlitProjectPaths,
3333
)
34-
from snowflake.cli.api.artifacts.upload import put_files
34+
from snowflake.cli.api.artifacts.upload import sync_artifacts_with_stage
3535
from snowflake.cli.api.commands.experimental_behaviour import (
3636
experimental_behaviour_enabled,
3737
)
@@ -56,18 +56,20 @@ def share(self, streamlit_name: FQN, to_role: str) -> SnowflakeCursor:
5656
f"grant usage on streamlit {streamlit_name.sql_identifier} to role {to_role}"
5757
)
5858

59-
def _put_streamlit_files(
59+
def _upload_artifacts(
6060
self,
6161
streamlit_project_paths: StreamlitProjectPaths,
6262
stage_root: str,
63+
prune: bool,
6364
artifacts: Optional[List[PathMapping]] = None,
6465
):
65-
cli_console.step(f"Deploying files to {stage_root}")
66-
put_files(
67-
project_paths=streamlit_project_paths,
68-
stage_root=stage_root,
69-
artifacts=artifacts,
70-
)
66+
with cli_console.phase(f"Deploying files to {stage_root}"):
67+
sync_artifacts_with_stage(
68+
project_paths=streamlit_project_paths,
69+
stage_root=stage_root,
70+
prune=prune,
71+
artifacts=artifacts,
72+
)
7173

7274
def _create_streamlit(
7375
self,
@@ -126,6 +128,7 @@ def deploy(
126128
streamlit: StreamlitEntityModel,
127129
streamlit_project_paths: StreamlitProjectPaths,
128130
replace: bool = False,
131+
prune: bool = False,
129132
):
130133
streamlit_id = streamlit.fqn.using_connection(self._conn)
131134
if (
@@ -182,10 +185,11 @@ def deploy(
182185
else:
183186
stage_root = f"{embedded_stage_name}/default_checkout"
184187

185-
self._put_streamlit_files(
188+
self._upload_artifacts(
186189
streamlit_project_paths,
187190
stage_root,
188-
streamlit.artifacts,
191+
prune=prune,
192+
artifacts=streamlit.artifacts,
189193
)
190194
else:
191195
"""
@@ -198,15 +202,15 @@ def deploy(
198202
stage_name = streamlit.stage or "streamlit"
199203
stage_name = FQN.from_string(stage_name).using_connection(self._conn)
200204

201-
cli_console.step(f"Creating {stage_name} stage")
202-
stage_manager.create(fqn=stage_name)
203-
204205
stage_root = stage_manager.get_standard_stage_prefix(
205206
f"{stage_name}/{streamlit_name_for_root_location}"
206207
)
207208

208-
self._put_streamlit_files(
209-
streamlit_project_paths, stage_root, streamlit.artifacts
209+
self._upload_artifacts(
210+
streamlit_project_paths,
211+
stage_root,
212+
prune=prune,
213+
artifacts=streamlit.artifacts,
210214
)
211215

212216
self._create_streamlit(

0 commit comments

Comments
 (0)