Skip to content

Commit 2574b13

Browse files
Jw/snow 2306289 support local output path (#2591)
* feat: [SNOW-2306289] support copying output files to local path * refactor: [SNOW-2306289] extract logic related to generating identifiers for temp stages to FQN.related_to_resource * feat: [SNOW-2306289] add more tests * refactor: [SNOW-2306289] apply review suggestions
1 parent e6bef27 commit 2574b13

File tree

10 files changed

+287
-123
lines changed

10 files changed

+287
-123
lines changed

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
from snowflake.cli.api.constants import DEFAULT_SIZE_LIMIT_MB, ObjectType
2828
from snowflake.cli.api.exceptions import CliError
2929
from snowflake.cli.api.identifiers import FQN
30-
from snowflake.cli.api.project.util import unquote_identifier
3130
from snowflake.cli.api.secure_path import SecurePath
3231
from snowflake.cli.api.sql_execution import SqlExecutionMixin
3332
from snowflake.connector.cursor import SnowflakeCursor
@@ -104,10 +103,9 @@ def deploy(
104103

105104
with cli_console.phase("Creating temporary stage"):
106105
stage_manager = StageManager()
107-
unquoted_name = unquote_identifier(fqn.name)
108-
stage_fqn = FQN.from_string(f"DBT_{unquoted_name}_STAGE").using_context()
109-
stage_name = stage_manager.get_standard_stage_prefix(stage_fqn)
106+
stage_fqn = FQN.from_resource(ObjectType.DBT_PROJECT, fqn, "STAGE")
110107
stage_manager.create(stage_fqn, temporary=True)
108+
stage_name = stage_manager.get_standard_stage_prefix(stage_fqn)
111109

112110
with cli_console.phase("Copying project files to stage"):
113111
with TemporaryDirectory() as tmp:

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ def plan(
142142
variables: Optional[List[str]] = variables_flag,
143143
configuration: Optional[str] = configuration_flag,
144144
output_path: Optional[str] = output_path_option(
145-
help="Stage path where the deployment plan output will be stored."
145+
help="Path where the deployment plan output will be stored. Can be a stage path (starting with '@') or a local directory path."
146146
),
147147
**options,
148148
):

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

Lines changed: 73 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,30 +11,72 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
import time
14+
from contextlib import contextmanager, nullcontext
1515
from pathlib import Path
16-
from typing import List
16+
from typing import Generator, List
1717

1818
import yaml
1919
from snowflake.cli._plugins.stage.manager import StageManager
2020
from snowflake.cli.api.artifacts.upload import sync_artifacts_with_stage
2121
from snowflake.cli.api.commands.utils import parse_key_value_variables
2222
from snowflake.cli.api.console.console import cli_console
23-
from snowflake.cli.api.constants import DEFAULT_SIZE_LIMIT_MB, PatternMatchingType
23+
from snowflake.cli.api.constants import (
24+
DEFAULT_SIZE_LIMIT_MB,
25+
ObjectType,
26+
PatternMatchingType,
27+
)
2428
from snowflake.cli.api.exceptions import CliError
2529
from snowflake.cli.api.identifiers import FQN
2630
from snowflake.cli.api.project.project_paths import ProjectPaths
2731
from snowflake.cli.api.project.schemas.entities.common import PathMapping
28-
from snowflake.cli.api.project.util import unquote_identifier
2932
from snowflake.cli.api.secure_path import SecurePath
3033
from snowflake.cli.api.sql_execution import SqlExecutionMixin
3134
from snowflake.cli.api.stage_path import StagePath
35+
from snowflake.cli.api.utils.path_utils import is_stage_path
3236

3337
MANIFEST_FILE_NAME = "manifest.yml"
3438
DCM_PROJECT_TYPE = "dcm_project"
3539

3640

3741
class DCMProjectManager(SqlExecutionMixin):
42+
@contextmanager
43+
def _collect_output(
44+
self, project_identifier: FQN, output_path: str
45+
) -> Generator[str, None, None]:
46+
"""
47+
Context manager for handling output path - creates temporary stage for local paths,
48+
downloads files after execution, and ensures proper cleanup.
49+
50+
Args:
51+
project_identifier: The DCM project identifier
52+
output_path: Either a stage path (@stage/path) or local directory path
53+
54+
Yields:
55+
str: The effective output path to use in the DCM command
56+
"""
57+
temp_stage_for_local_output = None
58+
stage_manager = StageManager()
59+
60+
if should_download_files := not is_stage_path(output_path):
61+
temp_stage_fqn = FQN.from_resource(
62+
ObjectType.DCM_PROJECT, project_identifier, "OUTPUT_TMP_STAGE"
63+
)
64+
stage_manager.create(temp_stage_fqn, temporary=True)
65+
effective_output_path = StagePath.from_stage_str(temp_stage_fqn.identifier)
66+
temp_stage_for_local_output = (temp_stage_fqn.identifier, Path(output_path))
67+
else:
68+
effective_output_path = StagePath.from_stage_str(output_path)
69+
70+
yield effective_output_path.absolute_path()
71+
72+
if should_download_files:
73+
assert temp_stage_for_local_output is not None
74+
stage_path, local_path = temp_stage_for_local_output
75+
stage_manager.get_recursive(stage_path=stage_path, dest_path=local_path)
76+
cli_console.step(f"Plan output saved to: {local_path.resolve()}")
77+
else:
78+
cli_console.step(f"Plan output saved to: {output_path}")
79+
3880
def execute(
3981
self,
4082
project_identifier: FQN,
@@ -45,28 +87,31 @@ def execute(
4587
alias: str | None = None,
4688
output_path: str | None = None,
4789
):
90+
with self._collect_output(project_identifier, output_path) if (
91+
output_path and dry_run
92+
) else nullcontext() as output_stage:
93+
query = f"EXECUTE DCM PROJECT {project_identifier.sql_identifier}"
94+
if dry_run:
95+
query += " PLAN"
96+
else:
97+
query += " DEPLOY"
98+
if alias:
99+
query += f' AS "{alias}"'
100+
if configuration or variables:
101+
query += f" USING"
102+
if configuration:
103+
query += f" CONFIGURATION {configuration}"
104+
if variables:
105+
query += StageManager.parse_execute_variables(
106+
parse_key_value_variables(variables)
107+
).removeprefix(" using")
108+
stage_path = StagePath.from_stage_str(from_stage)
109+
query += f" FROM {stage_path.absolute_path()}"
110+
if output_stage is not None:
111+
query += f" OUTPUT_PATH {output_stage}"
112+
result = self.execute_query(query=query)
48113

49-
query = f"EXECUTE DCM PROJECT {project_identifier.sql_identifier}"
50-
if dry_run:
51-
query += " PLAN"
52-
else:
53-
query += " DEPLOY"
54-
if alias:
55-
query += f' AS "{alias}"'
56-
if configuration or variables:
57-
query += f" USING"
58-
if configuration:
59-
query += f" CONFIGURATION {configuration}"
60-
if variables:
61-
query += StageManager.parse_execute_variables(
62-
parse_key_value_variables(variables)
63-
).removeprefix(" using")
64-
stage_path = StagePath.from_stage_str(from_stage)
65-
query += f" FROM {stage_path.absolute_path()}"
66-
if output_path:
67-
output_stage_path = StagePath.from_stage_str(output_path)
68-
query += f" OUTPUT_PATH {output_stage_path.absolute_path()}"
69-
return self.execute_query(query=query)
114+
return result
70115

71116
def create(self, project_identifier: FQN) -> None:
72117
query = f"CREATE DCM PROJECT {project_identifier.sql_identifier}"
@@ -114,10 +159,9 @@ def sync_local_files(project_identifier: FQN) -> str:
114159
definitions.append(MANIFEST_FILE_NAME)
115160

116161
with cli_console.phase(f"Uploading definition files"):
117-
unquoted_name = unquote_identifier(project_identifier.name)
118-
stage_fqn = FQN.from_string(
119-
f"DCM_{unquoted_name}_{int(time.time())}_TMP_STAGE"
120-
).using_context()
162+
stage_fqn = FQN.from_resource(
163+
ObjectType.DCM_PROJECT, project_identifier, "_TMP_STAGE"
164+
)
121165
sync_artifacts_with_stage(
122166
project_paths=ProjectPaths(project_root=Path.cwd()),
123167
stage_root=stage_fqn.identifier,

src/snowflake/cli/api/identifiers.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,20 @@
1515
from __future__ import annotations
1616

1717
import re
18+
import time
1819
from pathlib import Path
1920

2021
from click import ClickException
22+
from snowflake.cli.api.constants import ObjectType
2123
from snowflake.cli.api.exceptions import FQNInconsistencyError, FQNNameError
2224
from snowflake.cli.api.project.schemas.v1.identifier_model import (
2325
ObjectIdentifierBaseModel,
2426
)
25-
from snowflake.cli.api.project.util import VALID_IDENTIFIER_REGEX, identifier_for_url
27+
from snowflake.cli.api.project.util import (
28+
VALID_IDENTIFIER_REGEX,
29+
identifier_for_url,
30+
unquote_identifier,
31+
)
2632

2733

2834
class FQN:
@@ -167,6 +173,17 @@ def from_identifier_model_v2(cls, model) -> "FQN":
167173

168174
return fqn.set_database(model.database).set_schema(model.schema_)
169175

176+
@classmethod
177+
def from_resource(
178+
cls, resource_type: ObjectType, resource_fqn: FQN, purpose: str
179+
) -> "FQN":
180+
"""Create an instance related to another Snowflake resource."""
181+
unquoted_name = unquote_identifier(resource_fqn.name)
182+
safe_cli_name = resource_type.value.cli_name.upper().replace("-", "_")
183+
return cls.from_string(
184+
f"{safe_cli_name}_{unquoted_name}_{int(time.time())}_{purpose.upper()}"
185+
).using_context()
186+
170187
def set_database(self, database: str | None) -> "FQN":
171188
if database:
172189
self._database = database

tests/__snapshots__/test_help_messages.ambr

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8210,8 +8210,9 @@
82108210
| example: -D "<key>=<value>". |
82118211
| --configuration TEXT Configuration of the DCM Project to use. If |
82128212
| not specified default configuration is used. |
8213-
| --output-path TEXT Stage path where the deployment plan output |
8214-
| will be stored. |
8213+
| --output-path TEXT Path where the deployment plan output will be |
8214+
| stored. Can be a stage path (starting with |
8215+
| '@') or a local directory path. |
82158216
| --help -h Show this message and exit. |
82168217
+------------------------------------------------------------------------------+
82178218
+- Connection configuration ---------------------------------------------------+

tests/api/test_fqn.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from unittest.mock import MagicMock
1717

1818
import pytest
19+
from snowflake.cli.api.constants import ObjectType
1920
from snowflake.cli.api.exceptions import FQNInconsistencyError, FQNNameError
2021
from snowflake.cli.api.identifiers import FQN
2122
from snowflake.cli.api.project.schemas.v1.streamlit.streamlit import Streamlit
@@ -197,3 +198,54 @@ def test_using_context(mock_ctx):
197198
def test_git_fqn():
198199
fqn = FQN.from_stage_path("@git_repo/branches/main/devops/")
199200
assert fqn.name == "git_repo"
201+
202+
203+
class TestRelatedToResource:
204+
@pytest.fixture
205+
def mock_time(self):
206+
with mock.patch(
207+
"snowflake.cli.api.identifiers.time.time", return_value=1234567890
208+
) as _fixture:
209+
yield _fixture
210+
211+
@pytest.fixture
212+
def mock_ctx(self):
213+
with mock.patch(
214+
"snowflake.cli.api.cli_global_context.get_cli_context"
215+
) as _fixture:
216+
_fixture().connection = MagicMock(database="test_db", schema="test_schema")
217+
yield _fixture
218+
219+
def test_basic_functionality(self, mock_ctx, mock_time):
220+
resource_fqn = FQN(name="my_pipeline", database=None, schema=None)
221+
222+
result = FQN.from_resource(ObjectType.DBT_PROJECT, resource_fqn, "STAGE")
223+
224+
assert (
225+
result.identifier
226+
== "test_db.test_schema.DBT_PROJECT_MY_PIPELINE_1234567890_STAGE"
227+
)
228+
229+
def test_with_quoted_identifier(self, mock_ctx, mock_time):
230+
resource_fqn = FQN(name='"caseSenSITIVEnAME"', database=None, schema=None)
231+
232+
result = FQN.from_resource(ObjectType.DCM_PROJECT, resource_fqn, "TEMP_STAGE")
233+
234+
assert (
235+
result.identifier
236+
== "test_db.test_schema.DCM_caseSenSITIVEnAME_1234567890_TEMP_STAGE"
237+
)
238+
239+
def test_with_fqn_resource(self, mock_ctx, mock_time):
240+
mock_ctx().connection = MagicMock(
241+
database="context_db", schema="context_schema"
242+
)
243+
resource_fqn = FQN(
244+
name="resource", database="resource_db", schema="resource_schema"
245+
)
246+
247+
result = FQN.from_resource(ObjectType.STAGE, resource_fqn, "TEST")
248+
249+
assert result.database == "context_db"
250+
assert result.schema == "context_schema"
251+
assert result.name == "STAGE_RESOURCE_1234567890_TEST"

0 commit comments

Comments
 (0)