Fix snow stage copy --recursive for vstage (snow://) paths#2786
Fix snow stage copy --recursive for vstage (snow://) paths#2786sfc-gh-moczko wants to merge 2 commits intomainfrom
snow stage copy --recursive for vstage (snow://) paths#2786Conversation
de5191c to
926d6a8
Compare
926d6a8 to
5407c94
Compare
| elif stage_path.absolute_path().startswith(SNOW_PREFIX): | ||
| path = stage_path.root_path() / file["name"] | ||
| else: | ||
| # Snowflake `ls` returns unqualified names; re-attach the original FQN. |
There was a problem hiding this comment.
Nit: the else branch comment now only applies to @-stage paths. what do you think about
" For @-stage paths: Snowflake ls returns... "
There was a problem hiding this comment.
Fixed. The comment on the else branch now reads: "For @-stage paths: Snowflake ls returns unqualified names (e.g. `stage_name/path/file.sql`); re-attach the original FQN prefix so the result uses the correct qualified stage name."
src/snowflake/cli/api/stage_path.py
Outdated
| for s, o in zip(self_parts[: len(other_parts)], other_parts): | ||
| if s.lower() != o.lower(): |
There was a problem hiding this comment.
I am unsure here about being case-insensite. Snowflake preservers case sensitivity for quoted identifiers: "Data"/file.txt" should be different than "data"/file.txt". we should handle this case
There was a problem hiding this comment.
Fixed. relative_to() now uses per-component case rules:
- Unquoted identifiers (only
[A-Za-z0-9_$]) — Snowflake normalises these to lowercase inlsoutput, so we compare case-insensitively. - Quoted identifiers (any character outside that set, e.g. a dot or space) — Snowflake preserves their case, so we compare exactly.
The detection regex _UNQUOTED_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9_$]+$") is applied to each path component pair. If either component has characters outside the unquoted charset, we fall through to an exact match. Added test_relative_to_quoted_identifier_success and test_relative_to_raises_for_non_subpath to cover both cases.
| path = StagePath.get_user_stage() / file["name"] | ||
| elif stage_path.is_git_repo(): | ||
| path = self.build_path(file["name"]) | ||
| elif stage_path.absolute_path().startswith(SNOW_PREFIX): |
There was a problem hiding this comment.
Nit, not a blocker: this can be encapsulated with new is_vstage method on StagePath
There was a problem hiding this comment.
Done. Added is_vstage() method to StagePath and updated iter_stage() to use it (elif stage_path.is_vstage():). Also added parametrised test_is_vstage and test_is_vstage_preserved_through_operations to verify the method returns the correct value through joinpath, parent, and root_path.
…e iter_stage comment (#2786) - Add StagePath.is_vstage() method returning _is_snow_prefixed_stage, mirroring is_user_stage() and is_git_repo(); use it in iter_stage() instead of leaking the SNOW_PREFIX constant into manager.py. - Fix relative_to() to apply case-insensitive comparison only for unquoted identifier components ([A-Za-z0-9_$]+), which Snowflake normalizes to lowercase in ls output. Components containing other characters (dots, spaces, etc.) imply quoted identifiers whose case Snowflake preserves, so those are compared exactly. - Update the else-branch comment in iter_stage() to clarify it applies to @-stage paths only, now that vstage has its own elif branch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…e iter_stage comment (#2786) - Add StagePath.is_vstage() method returning _is_snow_prefixed_stage, mirroring is_user_stage() and is_git_repo(); use it in iter_stage() instead of leaking the SNOW_PREFIX constant into manager.py. - Fix relative_to() to apply case-insensitive comparison only for unquoted identifier components ([A-Za-z0-9_$]+), which Snowflake normalizes to lowercase in ls output. Components containing other characters (dots, spaces, etc.) imply quoted identifiers whose case Snowflake preserves, so those are compared exactly. - Update the else-branch comment in iter_stage() to clarify it applies to @-stage paths only, now that vstage has its own elif branch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
80b5581 to
d83dada
Compare
Two bugs prevented recursive copy from working with vstage paths (e.g., `snow://project/.../deployments/DEPLOYMENT$1/`): 1. `iter_stage()` lost the `snow://` prefix when reconstructing file paths from `ls` output. Snowflake's `ls` returns plain relative paths (e.g., `deployments/DEPLOYMENT$1/manifest.yml`) without the `snow://` prefix. The existing code passed these to `build_path()`, which treated them as `@`-prefixed stage paths, producing invalid SQL like `get @deployments/DEPLOYMENT$1/manifest.yml ...` instead of `get 'snow://project/.../manifest.yml' ...`. Fix: Add a vstage-aware branch in `iter_stage()` that reconstructs full paths via `stage_path.root_path() / file["name"]` when the stage path starts with `snow://`. 2. `StagePath.relative_to()` used `PurePosixPath.relative_to()` which is case-sensitive. Snowflake normalizes unquoted identifiers to lowercase in `ls` output (e.g., `DEPLOYMENT$1` becomes `deployment$1`), causing `ValueError` when computing relative paths for local directory structure during recursive download. Fix: Replace with case-insensitive part-by-part comparison that preserves original casing in the returned relative path. Verified against a real DCM project on Snowflake -- 7 files downloaded successfully with correct directory structure preserved. .... Generated with [Cortex Code](https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code) Co-Authored-By: Cortex Code <noreply@snowflake.com>
…e iter_stage comment (#2786) - Add StagePath.is_vstage() method returning _is_snow_prefixed_stage, mirroring is_user_stage() and is_git_repo(); use it in iter_stage() instead of leaking the SNOW_PREFIX constant into manager.py. - Fix relative_to() to apply case-insensitive comparison only for unquoted identifier components ([A-Za-z0-9_$]+), which Snowflake normalizes to lowercase in ls output. Components containing other characters (dots, spaces, etc.) imply quoted identifiers whose case Snowflake preserves, so those are compared exactly. - Update the else-branch comment in iter_stage() to clarify it applies to @-stage paths only, now that vstage has its own elif branch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
d83dada to
53115f9
Compare
Summary
Fixes two bugs that caused
snow stage copy --recursiveto fail when downloading from vstage (snow://) paths, particularly DCM deployment stages likesnow://project/.../deployments/DEPLOYMENT$1/.Bug 1 —
iter_stage()losessnow://prefix: Whenlsreturns plain relative paths (e.g.,deployments/DEPLOYMENT$1/manifest.yml),build_path()misinterprets them as@-prefixed stage paths, generating invalid SQL (get @deployments/...instead ofget 'snow://project/...'). Fixed by adding a vstage-aware branch that reconstructs full paths viastage_path.root_path() / file["name"].Bug 2 —
StagePath.relative_to()is case-sensitive: Snowflake normalizes unquoted identifiers to lowercase inlsoutput (DEPLOYMENT$1becomesdeployment$1), butPurePosixPath.relative_to()is case-sensitive, causingValueErrorwhen computing local directory structure. Fixed with case-insensitive part-by-part comparison.Files changed
src/snowflake/cli/_plugins/stage/manager.pyelifbranch initer_stage()forsnow://pathssrc/snowflake/cli/api/stage_path.pyrelative_to()with case-insensitive implementationtests/stage/test_stage.pytests/stage/test_stage_path.pyTest plan