Skip to content

Commit 53455e2

Browse files
Snow 2097403 add encryption to stage create (#2300)
* Add --encryption option to stage create * fix unit tests * integration tests * update release notes * review fixes
1 parent e9b484f commit 53455e2

File tree

6 files changed

+72
-11
lines changed

6 files changed

+72
-11
lines changed

RELEASE-NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
## New additions
2222
* Added `--private-link` flag to `snow spcs image-registry url` command for retrieving private link URLs.
23+
* Added `--encryption` flag to `snow stage create` command defining the type of encryption for all files on the stage.
2324

2425
## Fixes and improvements
2526

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@
2929
DiffResult,
3030
compute_stage_diff,
3131
)
32-
from snowflake.cli._plugins.stage.manager import StageManager
32+
from snowflake.cli._plugins.stage.manager import (
33+
InternalStageEncryptionType,
34+
StageManager,
35+
)
3336
from snowflake.cli._plugins.stage.utils import print_diff_to_console
3437
from snowflake.cli.api.cli_global_context import get_cli_context
3538
from snowflake.cli.api.commands.common import OnErrorType
@@ -150,11 +153,19 @@ def copy(
150153

151154

152155
@app.command("create", requires_connection=True)
153-
def stage_create(stage_name: FQN = StageNameArgument, **options) -> CommandResult:
156+
def stage_create(
157+
stage_name: FQN = StageNameArgument,
158+
encryption: InternalStageEncryptionType = typer.Option(
159+
InternalStageEncryptionType.SNOWFLAKE_FULL.value,
160+
"--encryption",
161+
help="Type of encryption supported for all files stored on the stage.",
162+
),
163+
**options,
164+
) -> CommandResult:
154165
"""
155166
Creates a named stage if it does not already exist.
156167
"""
157-
cursor = StageManager().create(fqn=stage_name)
168+
cursor = StageManager().create(fqn=stage_name, encryption=encryption)
158169
return SingleQueryResult(cursor)
159170

160171

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from collections import deque
2626
from contextlib import nullcontext
2727
from dataclasses import dataclass
28+
from enum import Enum
2829
from os import path
2930
from pathlib import Path
3031
from tempfile import TemporaryDirectory
@@ -68,6 +69,11 @@
6869
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>([^/]*/?)*)?"
6970

7071

72+
class InternalStageEncryptionType(Enum):
73+
SNOWFLAKE_FULL = "SNOWFLAKE_FULL"
74+
SNOWFLAKE_SSE = "SNOWFLAKE_SSE"
75+
76+
7177
@dataclass
7278
class StagePathParts:
7379
directory: str
@@ -515,10 +521,16 @@ def remove(
515521
return self.execute_query(f"remove {stage_path.path_for_sql()}")
516522

517523
def create(
518-
self, fqn: FQN, comment: Optional[str] = None, temporary: bool = False
524+
self,
525+
fqn: FQN,
526+
comment: Optional[str] = None,
527+
temporary: bool = False,
528+
encryption: InternalStageEncryptionType | None = None,
519529
) -> SnowflakeCursor:
520530
temporary_str = "temporary " if temporary else ""
521531
query = f"create {temporary_str}stage if not exists {fqn.sql_identifier}"
532+
if encryption:
533+
query += f" encryption = (type = '{encryption.value}')"
522534
if comment:
523535
query += f" comment='{comment}'"
524536
return self.execute_query(query)

tests/__snapshots__/test_help_messages.ambr

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15173,7 +15173,12 @@
1517315173
| [required] |
1517415174
+------------------------------------------------------------------------------+
1517515175
+- Options --------------------------------------------------------------------+
15176-
| --help -h Show this message and exit. |
15176+
| --encryption [SNOWFLAKE_FULL|SNOWFLAKE_ Type of encryption |
15177+
| SSE] supported for all files |
15178+
| stored on the stage. |
15179+
| [default: SNOWFLAKE_FULL] |
15180+
| --help -h Show this message and |
15181+
| exit. |
1517715182
+------------------------------------------------------------------------------+
1517815183
+- Connection configuration ---------------------------------------------------+
1517915184
| --connection,--environment -c TEXT Name of the connection, as |

tests/stage/test_stage.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def test_stage_copy_remote_to_local_quoted_stage_recursive(
114114
):
115115
mock_execute.side_effect = [
116116
mock_cursor([{"name": '"stage name"/file'}], []),
117-
mock_cursor([("file")], ["file"]),
117+
mock_cursor(["file"], ["file"]),
118118
]
119119
with TemporaryDirectory() as tmp_dir:
120120
result = runner.invoke(
@@ -191,7 +191,7 @@ def test_stage_copy_remote_to_local_quoted_uri_recursive(
191191
):
192192
mock_execute.side_effect = [
193193
mock_cursor([{"name": "stageName/file.py"}], []),
194-
mock_cursor([(raw_path)], ["file"]),
194+
mock_cursor([raw_path], ["file"]),
195195
]
196196
with TemporaryDirectory() as tmp_dir:
197197
tmp_dir = Path(tmp_dir).resolve()
@@ -516,17 +516,41 @@ def test_stage_create(mock_execute, runner, mock_cursor):
516516
result = runner.invoke(["stage", "create", "-c", "empty", "stageName"])
517517
assert result.exit_code == 0, result.output
518518
mock_execute.assert_called_once_with(
519-
"create stage if not exists IDENTIFIER('stageName')"
519+
"create stage if not exists IDENTIFIER('stageName') encryption = (type = 'SNOWFLAKE_FULL')"
520520
)
521521

522522

523+
@mock.patch(f"{STAGE_MANAGER}.execute_query")
524+
def test_stage_create_encryption(mock_execute, runner, mock_cursor):
525+
mock_execute.return_value = mock_cursor(["row"], [])
526+
for encryption in ["SNOWFLAKE_SSE", "SNOWFLAKE_FULL"]:
527+
result = runner.invoke(
528+
["stage", "create", '"stage name"', "--encryption", encryption]
529+
)
530+
assert result.exit_code == 0, result.output
531+
mock_execute.assert_called_once_with(
532+
f"""create stage if not exists IDENTIFIER('"stage name"') encryption = (type = '{encryption}')"""
533+
)
534+
mock_execute.reset_mock()
535+
536+
result = runner.invoke(
537+
["stage", "create", '"stage name"', "--encryption", "incorrect_encryption"]
538+
)
539+
assert result.exit_code == 2, result.output
540+
assert (
541+
"Invalid value for '--encryption': 'incorrect_encryption' is not one of"
542+
in result.output
543+
)
544+
assert "'SNOWFLAKE_FULL', 'SNOWFLAKE_SSE'." in result.output
545+
546+
523547
@mock.patch(f"{STAGE_MANAGER}.execute_query")
524548
def test_stage_create_quoted(mock_execute, runner, mock_cursor):
525549
mock_execute.return_value = mock_cursor(["row"], [])
526550
result = runner.invoke(["stage", "create", "-c", "empty", '"stage name"'])
527551
assert result.exit_code == 0, result.output
528552
mock_execute.assert_called_once_with(
529-
"""create stage if not exists IDENTIFIER('"stage name"')"""
553+
"""create stage if not exists IDENTIFIER('"stage name"') encryption = (type = 'SNOWFLAKE_FULL')"""
530554
)
531555

532556

tests_integration/test_stage.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@
1818
import tempfile
1919
import time
2020
from pathlib import Path
21-
2221
import pytest
23-
from snowflake.connector import DictCursor
2422

2523
from tests.stage.test_stage import RecursiveUploadTester, NESTED_STRUCTURE
2624
from tests_integration.test_utils import (
@@ -819,3 +817,13 @@ def test_recursive_upload_no_recursive_glob_pattern(
819817
"target_size": 16,
820818
}
821819
]
820+
821+
822+
@pytest.mark.integration
823+
@pytest.mark.parametrize("encryption", ["SNOWFLAKE_FULL", "SNOWFLAKE_SSE"])
824+
def test_create_encryption(runner, test_database, encryption):
825+
result = runner.invoke_with_connection_json(
826+
["stage", "create", "a_stage", "--encryption", encryption]
827+
)
828+
assert result.exit_code == 0, result.output
829+
assert result.json == {"status": f"Stage area A_STAGE successfully created."}

0 commit comments

Comments
 (0)