Skip to content

Commit f955cde

Browse files
sfc-gh-svishnucursoragent
authored andcommitted
fix: [SNOW-3820767] update versioned Streamlit entrypoints in place
* SNOW-3820764: Scope object list in create integration test Unfiltered SHOW DATABASES hits error 090153 on accounts with >10k databases. Use --like with the created object name instead. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: [SNOW-3820767] safely update versioned Streamlit entrypoints * test: [SNOW-3820767] overwrite Streamlit stage sentinel in integration setup * test: [SNOW-3820767] omit warehouse in Streamlit preservation coverage --------- Co-authored-by: Cursor <cursoragent@cursor.com> GitOrigin-RevId: 528032ba8e2119909a75612652d7a2a22151a61c
1 parent d91c244 commit f955cde

13 files changed

Lines changed: 710 additions & 48 deletions

File tree

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,6 @@ plans/
2929
.mcp.json
3030
.ai/review/**/*.json
3131
/tests/logs/snowflake-cli.log
32+
33+
# mission-ops artifacts
34+
.claude/missions/

‎RELEASE-NOTES.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
* `snow upgrade` is now generally available. It upgrades a direct install of Snowflake CLI and is listed in `snow --help` without a feature flag. Other install channels refuse and print the direct-install command.
2525

2626
## Fixes and improvements
27+
* `snow streamlit deploy --replace` now applies `main_file` changes to existing versioned apps in place, preserving the app, grants and version history. Files are uploaded before the entrypoint changes; `--prune` runs only after the change succeeds. Accounts that reject the update receive an actionable error.
28+
* Streamlit deploy now refuses to recreate an existing app with no live stage unless a legacy `ROOT_LOCATION` is known, and rejects `--prune` with an unset `main_file` when the active entrypoint is absent from the bundle.
2729
* Upgraded the Python interpreter embedded in Linux binaries from 3.10.22 to 3.12.15. macOS and Windows binaries now embed Python 3.12 as well.
2830

2931

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,24 @@ def _check_file_exists_if_not_default(ctx: click.Context, value):
477477
" option, the command removes files that exist in the stage, but not files "
478478
"in the local filesystem.",
479479
),
480+
plain_text(
481+
"For an existing versioned app, ",
482+
code("--replace"),
483+
" applies changes to ",
484+
code("main_file"),
485+
" in place. Files are uploaded before the "
486+
"entrypoint changes, and pruning runs after a successful change. If the "
487+
"account does not support the update, deploy reports an error and keeps "
488+
"the existing app.",
489+
),
490+
plain_text(
491+
"If the app has no live version stage, restore its live version before "
492+
"redeploying. When ",
493+
code("main_file"),
494+
" is unset, ",
495+
code("--prune"),
496+
" requires the app's current entrypoint to be included in the bundle.",
497+
),
480498
),
481499
examples=(
482500
Example(

‎src/snowflake/cli/_plugins/streamlit/streamlit_entity.py‎

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import logging
3+
from functools import partial
34
from pathlib import Path
45
from typing import Any, Dict, NamedTuple, Optional
56

@@ -38,6 +39,7 @@
3839
# Snowflake errno / SQLSTATE for "live version already exists" (same codes as
3940
# SnowflakeAppManager.ensure_workspace_live_version).
4041
_LIVE_VERSION_EXISTS_ERRNO = 99106
42+
_MAIN_FILE_UNSUPPORTED_ERRNO = 92824
4143
_RESTART_STREAMLIT_FUNCTION = "SYSTEM$RESTART_STREAMLIT"
4244

4345

@@ -441,10 +443,12 @@ def _list(v: Any) -> list:
441443
if from_stage_name:
442444
clauses.append(f"ROOT_LOCATION = {to_string_literal(from_stage_name)}")
443445

444-
if legacy:
445-
desired = _str(self._entity_model.main_file)
446-
if not current or _str(cur.get("main_file")) != desired:
447-
clauses.append(f"MAIN_FILE = {to_string_literal(desired)}")
446+
if self.model.main_file == "":
447+
raise CliError(
448+
"main_file cannot be empty. Set it to the app's entrypoint file."
449+
)
450+
if self._main_file_changed(cur):
451+
clauses.append(f"MAIN_FILE = {to_string_literal(self.model.main_file)}")
448452

449453
# Only snowflake.yml changes the warehouse of an existing app. Falling
450454
# back to the connection here would rewrite a live warehouse, or fail
@@ -706,8 +710,11 @@ def _is_legacy_deployment(self) -> bool:
706710
"""Check if the existing streamlit uses legacy ROOT_LOCATION deployment."""
707711
try:
708712
result = self.describe().fetchone()
709-
# Versioned deployments have live_version_location_uri, legacy ones don't
710-
return result.get("live_version_location_uri") is None
713+
# A versioned app can also lack a live version. Only a positive
714+
# ROOT_LOCATION identifies an app eligible for legacy conversion.
715+
return bool(result.get("root_location")) and not result.get(
716+
"live_version_location_uri"
717+
)
711718
except (ProgrammingError, AttributeError, KeyError):
712719
# If we can't determine, assume it doesn't exist or is inaccessible
713720
return False
@@ -852,13 +859,27 @@ def _restart_running_app(self) -> None:
852859
)
853860
self._sql_executor.execute_query_with_params(restart_sql, (identifier,))
854861

862+
def _main_file_changed(self, current: Dict[str, Any]) -> bool:
863+
"""Compare entrypoints exactly, preserving whitespace in file names."""
864+
desired = self.model.main_file
865+
return desired is not None and desired != current.get("main_file")
866+
867+
@staticmethod
868+
def _entrypoint_is_bundled(bundle_map: BundleMap, main_file: Optional[str]) -> bool:
869+
return bool(
870+
main_file
871+
and bundle_map.to_project_path(Path(main_file)) is not None
872+
and (bundle_map.deploy_root() / main_file).is_file()
873+
)
874+
855875
def _deploy_versioned(
856876
self,
857877
bundle_map: BundleMap,
858878
prune: bool = False,
859879
object_exists: bool = False,
860880
):
861881
restart_after_upload = False
882+
deferred_alter_sql = None
862883
if object_exists:
863884
current = self.describe().fetchone() or {}
864885
stage_root = current.get("live_version_location_uri")
@@ -867,6 +888,12 @@ def _deploy_versioned(
867888
# ALTER + ADD LIVE VERSION on an object that still has
868889
# ROOT_LOCATION (unverified / leaves a hybrid state).
869890
old_root = current.get("root_location")
891+
if not old_root:
892+
raise CliError(
893+
"The existing Streamlit app has no live version stage. "
894+
"Restore its live version or deploy with a different "
895+
"identifier, then retry. The existing app was not recreated."
896+
)
870897
self._execute_query(
871898
self.get_deploy_sql(
872899
replace=True,
@@ -877,12 +904,30 @@ def _deploy_versioned(
877904
stage_root = self._ensure_live_version_location_uri()
878905
self._copy_legacy_stage_if_present(old_root, stage_root)
879906
else:
880-
# Already versioned — update properties in place and upload to
881-
# the existing live URI (do not re-issue ADD LIVE VERSION).
907+
if prune and self.model.main_file is None:
908+
active_main = current.get("main_file")
909+
if not self._entrypoint_is_bundled(bundle_map, active_main):
910+
raise CliError(
911+
"Cannot prune with main_file unset when the app's current "
912+
"entrypoint is missing from the bundle. Include the existing "
913+
"entrypoint in artifacts or set main_file before pruning."
914+
)
915+
# Upload a new entrypoint before switching to it. Keep the old
916+
# file until ALTER succeeds, even when --prune was requested.
882917
alter_sql = self.get_alter_sql(current=current)
883-
if alter_sql:
884-
self._execute_query(alter_sql)
885-
self._sync_tags()
918+
if self._main_file_changed(current):
919+
if not self._entrypoint_is_bundled(
920+
bundle_map, self.model.main_file
921+
):
922+
raise CliError(
923+
"The new main_file is not a file in the deployment bundle. "
924+
"Set main_file to the app's entrypoint file before deploying."
925+
)
926+
deferred_alter_sql = alter_sql
927+
else:
928+
if alter_sql:
929+
self._execute_query(alter_sql)
930+
self._sync_tags()
886931
# DESCRIBE, not snowflake.yml: a content-only project can omit
887932
# runtime_name while the live object is already SPCS v2.
888933
restart_after_upload = _describe_row_is_spcs_v2(current)
@@ -908,16 +953,37 @@ def _deploy_versioned(
908953
) from exc
909954
stage_path_parts = StageManager().stage_path_parts_from_str(stage_root)
910955

911-
diff = sync_deploy_root_with_stage(
956+
sync_bundle = partial(
957+
sync_deploy_root_with_stage,
912958
console=self._workspace_ctx.console,
913959
deploy_root=bundle_map.deploy_root(),
914960
bundle_map=bundle_map,
915-
prune=prune,
916961
recursive=True,
917962
stage_path_parts=stage_path_parts,
918963
print_diff=True,
919964
force_overwrite=True, # files copied to streamlit vstage need to be overwritten
920965
)
966+
diff = sync_bundle(
967+
prune=prune and not deferred_alter_sql,
968+
warn_on_preserved_files=not (prune and deferred_alter_sql),
969+
)
970+
971+
if deferred_alter_sql:
972+
try:
973+
self._execute_query(deferred_alter_sql)
974+
except ProgrammingError as exc:
975+
if getattr(exc, "errno", None) != _MAIN_FILE_UNSUPPORTED_ERRNO:
976+
raise
977+
raise CliError(
978+
"This account does not support changing main_file on a versioned "
979+
"Streamlit app. Keep the existing main_file or deploy with a "
980+
"different identifier. The existing app was not recreated."
981+
) from exc
982+
self._sync_tags()
983+
if prune:
984+
prune_diff = sync_bundle(prune=True)
985+
if prune_diff.has_changes():
986+
diff = prune_diff
921987

922988
StreamlitManager(connection=self._conn).grant_privileges(self.model)
923989
# Property-only ALTER does not clear ScriptCache. Skip when the stage

‎src/snowflake/cli/api/entities/utils.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def sync_deploy_root_with_stage(
9393
local_paths_to_sync: List[Path] | None = None,
9494
print_diff: bool = True,
9595
force_overwrite: bool = False,
96+
warn_on_preserved_files: bool = True,
9697
) -> DiffResult:
9798
"""
9899
Ensures that the files on our remote stage match the artifacts we have in
@@ -112,6 +113,7 @@ def sync_deploy_root_with_stage(
112113
local paths. Note that providing an empty list here is equivalent to None.
113114
print_diff (bool): Whether to print the diff between the local files and the remote stage. Defaults to True
114115
force_overwrite (bool): Some resources (e.g. streamlit) need to overwrite files on the stage. Defaults to False.
116+
warn_on_preserved_files (bool): Warn about stage-only files when not pruning. Defaults to True.
115117
116118
Returns:
117119
A `DiffResult` instance describing the changes that were performed.
@@ -192,7 +194,7 @@ def sync_deploy_root_with_stage(
192194
files_not_removed = [str(path) for path in diff.only_on_stage]
193195
diff.only_on_stage = []
194196

195-
if len(files_not_removed) > 0:
197+
if files_not_removed and warn_on_preserved_files:
196198
files_not_removed_str = "\n".join(files_not_removed)
197199
console.warning(
198200
f"The following files exist only on the stage:\n{files_not_removed_str}\n\nUse the --prune flag to delete them from the stage."

‎tests/__snapshots__/test_help_messages.ambr‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16363,6 +16363,15 @@
1636316363
| |
1636416364
| If you specify the --prune option, the command removes files that exist in |
1636516365
| the stage, but not files in the local filesystem. |
16366+
| |
16367+
| For an existing versioned app, --replace applies changes to main_file in |
16368+
| place. Files are uploaded before the entrypoint changes, and pruning runs |
16369+
| after a successful change. If the account does not support the update, |
16370+
| deploy reports an error and keeps the existing app. |
16371+
| |
16372+
| If the app has no live version stage, restore its live version before |
16373+
| redeploying. When main_file is unset, --prune requires the app's current |
16374+
| entrypoint to be included in the bundle. |
1636616375
+------------------------------------------------------------------------------+
1636716376
+- Examples -------------------------------------------------------------------+
1636816377
| snow streamlit deploy demo_app --replace |

‎tests/nativeapp/test_manager.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,18 +241,21 @@ def test_sync_deploy_root_with_stage_subdir(
241241
@mock.patch(f"{ENTITIES_UTILS_MODULE}.sync_local_diff_with_stage")
242242
@mock.patch(f"{ENTITIES_UTILS_MODULE}.compute_stage_diff")
243243
@pytest.mark.parametrize(
244-
"prune,only_on_stage_files,expected_warn",
244+
"prune,only_on_stage_files,warn_on_preserved_files,expected_warn",
245245
[
246246
[
247247
True,
248248
[StagePathType("only-stage.txt")],
249+
None,
249250
False,
250251
],
251252
[
252253
False,
253254
[StagePathType("only-stage-1.txt"), StagePathType("only-stage-2.txt")],
255+
None,
254256
True,
255257
],
258+
[False, [StagePathType("old-entrypoint.py")], False, False],
256259
],
257260
)
258261
def test_sync_deploy_root_with_stage_prune(
@@ -262,6 +265,7 @@ def test_sync_deploy_root_with_stage_prune(
262265
mock_stage_exists,
263266
prune,
264267
only_on_stage_files,
268+
warn_on_preserved_files,
265269
expected_warn,
266270
temporary_directory,
267271
):
@@ -279,6 +283,11 @@ def test_sync_deploy_root_with_stage_prune(
279283
package_name = pkg_model.fqn.name
280284
stage_fqn = f"{package_name}.{pkg_model.stage}"
281285
mock_console = mock.MagicMock()
286+
warning_options = (
287+
{}
288+
if warn_on_preserved_files is None
289+
else {"warn_on_preserved_files": warn_on_preserved_files}
290+
)
282291
sync_deploy_root_with_stage(
283292
console=mock_console,
284293
deploy_root=dm.project_root / pkg_model.deploy_root,
@@ -288,8 +297,12 @@ def test_sync_deploy_root_with_stage_prune(
288297
prune=prune,
289298
recursive=True,
290299
stage_path_parts=DefaultStagePathParts.from_fqn(stage_fqn),
300+
**warning_options,
291301
)
292302

303+
if not prune:
304+
assert mock_compute_stage_diff.return_value.only_on_stage == []
305+
293306
if expected_warn:
294307
files_str = "\n".join([str(f) for f in only_on_stage_files])
295308
warn_message = f"""The following files exist only on the stage:

‎tests/streamlit/streamlit_test_class.py‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1+
from pathlib import Path
12
from re import match
23
from typing import List
34
from unittest import mock
45

56
from snowflake.cli._plugins.stage.diff import DiffResult
7+
from snowflake.cli._plugins.streamlit.streamlit_entity import StreamlitEntity
8+
from snowflake.cli._plugins.streamlit.streamlit_entity_model import StreamlitEntityModel
9+
from snowflake.cli._plugins.workspace.context import WorkspaceContext
10+
from snowflake.cli.api.console.abc import AbstractConsole
11+
from snowflake.cli.api.secure_path import SecurePath
612

713
from tests.conftest import MockCursor
814

@@ -51,6 +57,34 @@ def stage_diff_unchanged() -> DiffResult:
5157

5258

5359
class StreamlitTestClass:
60+
@staticmethod
61+
def _create_entity(
62+
project_root: Path,
63+
main_file: str,
64+
artifacts,
65+
include_project_definition: bool = False,
66+
):
67+
workspace_ctx = WorkspaceContext(
68+
console=mock.MagicMock(spec=AbstractConsole),
69+
project_root=project_root,
70+
get_default_role=lambda: "mock_role",
71+
get_default_warehouse=lambda: "mock_warehouse",
72+
)
73+
model = StreamlitEntityModel(
74+
type="streamlit",
75+
identifier="test_streamlit",
76+
main_file=main_file,
77+
artifacts=artifacts,
78+
include_project_definition=include_project_definition,
79+
)
80+
model.set_entity_id("test_streamlit")
81+
return StreamlitEntity(workspace_ctx=workspace_ctx, entity_model=model)
82+
83+
@staticmethod
84+
def _write_file(path: Path, content: str = ""):
85+
SecurePath(path.parent).mkdir(parents=True, exist_ok=True)
86+
SecurePath(path).write_text(content)
87+
5488
def setup_method(self):
5589
self.mock_conn = mock.patch(
5690
"snowflake.cli._plugins.streamlit.streamlit_entity.StreamlitEntity._conn"
@@ -98,7 +132,8 @@ def setup_method(self):
98132
).start()
99133
mock_cursor = mock.Mock()
100134
mock_cursor.fetchone.return_value = {
101-
"live_version_location_uri": f"snow://streamlit/DB.PUBLIC.{STREAMLIT_NAME}/versions/live/"
135+
"live_version_location_uri": f"snow://streamlit/DB.PUBLIC.{STREAMLIT_NAME}/versions/live/",
136+
"main_file": "streamlit_app.py",
102137
}
103138
self.mock_describe.return_value = mock_cursor
104139

0 commit comments

Comments
 (0)