-
Notifications
You must be signed in to change notification settings - Fork 58
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add optional deploy files argument #933
Changes from 57 commits
ad43ea1
041b385
159752b
dc4c967
b95962d
6265837
2149053
f8b8c5e
7a07388
9fa9f44
1e29c4b
e2119c3
5616e01
9c4a902
3a1e46d
29994b0
f7963d8
1e82b1c
e9c593b
4031f68
a003915
dc3ec90
28f916d
9749655
c60b20c
421607e
868314d
4c03ee3
da14615
6392691
e162cda
1f22260
f4d01f5
39c0d36
b6b80d2
38ddfd6
3ac8f27
89381dc
3da37b1
488cac8
0acd51b
9ba1201
c961ccd
3f94f4e
fead52e
1177c8b
e79ae17
60551ef
b69921b
d1e38b2
26497cb
7fff826
aecc5b4
7176727
6ffbcf3
8d89da9
fc58314
b41ae52
26d0407
2dcd2d7
4d179c5
ccea424
7ca66fa
f7bc13e
b57f7d2
4568237
c7be70e
fbd6b04
a282972
ef3dedd
4e4bf85
b8f0650
e1cc96d
e3550de
63db862
1ea1785
ece7214
4e39f76
be7d3a1
19ab8a2
5b55216
dd293e9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,14 +1,17 @@ | ||
import os | ||
from dataclasses import dataclass | ||
from pathlib import Path | ||
from typing import List, Optional, Tuple, Union | ||
from typing import Dict, List, Optional, Tuple, Union | ||
|
||
from click import ClickException | ||
from click.exceptions import ClickException | ||
from snowflake.cli.api.constants import DEFAULT_SIZE_LIMIT_MB | ||
from snowflake.cli.api.project.schemas.native_app.path_mapping import PathMapping | ||
from snowflake.cli.api.secure_path import SecurePath | ||
from yaml import safe_load | ||
|
||
# Map from source directories and files in the project directory to their path in the deploy directory. Both paths are absolute. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do they need to be absolute? Intuitively, I think of source paths as relative to the project root, and dest paths as relative to the deployment directory. My guess is that without this invariant, you'd have to drag both roots around, is that it? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I started with relative paths, but switched to absolute mostly to canonicalize windows paths ( There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well, the deploy root could be anything, we should never assume that it's within the project directory. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In an ideal world, these would be relative to their respective roots, but it's documented well and if we need them we can always use |
||
ArtifactDeploymentMap = Dict[Path, Path] | ||
|
||
|
||
class DeployRootError(ClickException): | ||
""" | ||
|
@@ -196,10 +199,11 @@ def resolve_without_follow(path: Path) -> Path: | |
|
||
def build_bundle( | ||
project_root: Path, deploy_root: Path, artifacts: List[ArtifactMapping] | ||
): | ||
) -> ArtifactDeploymentMap: | ||
""" | ||
Prepares a local folder (deploy_root) with configured app artifacts. | ||
This folder can then be uploaded to a stage. | ||
Returns a mapping of the copied source files, pointing to where they were copied to. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. copied. (remove "to") Also, is it a mapping or a map that gets returned? From the code, it looks like it returns a Map. |
||
""" | ||
resolved_root = deploy_root.resolve() | ||
if resolved_root.exists() and not resolved_root.is_dir(): | ||
|
@@ -217,6 +221,7 @@ def build_bundle( | |
if resolved_root.exists(): | ||
delete(resolved_root) | ||
|
||
created_files: ArtifactDeploymentMap = {} | ||
for artifact in artifacts: | ||
dest_path = resolve_without_follow(Path(resolved_root, artifact.dest)) | ||
source_paths = get_source_paths(artifact, project_root) | ||
|
@@ -228,7 +233,9 @@ def build_bundle( | |
|
||
# copy all files as children of the given destination path | ||
for source_path in source_paths: | ||
symlink_or_copy(source_path, dest_path / source_path.name) | ||
dest_child_path = dest_path / source_path.name | ||
symlink_or_copy(source_path, dest_child_path) | ||
created_files[source_path.resolve()] = dest_child_path | ||
else: | ||
# ensure we are copying into the deploy root, not replacing it! | ||
if resolved_root not in dest_path.parents: | ||
|
@@ -237,9 +244,11 @@ def build_bundle( | |
if len(source_paths) == 1: | ||
# copy a single file as the given destination path | ||
symlink_or_copy(source_paths[0], dest_path) | ||
created_files[source_paths[0].resolve()] = dest_path | ||
else: | ||
# refuse to map multiple source files to one destination (undefined behaviour) | ||
raise TooManyFilesError(dest_path) | ||
return created_files | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: I keep getting back to this name, and something feels off. I think it's because in my mind, bundle is something that is essentially acting on a diff. So it becomes unclear here if "created" means newly added files, or all files that are supposed to be mapped. It's the latter of course, reading the code, but the name should reflect that. How about something a lot more generic like "deploy_root_file_mapping" / "deploy_root_files" / "mapped_files" etc? Might want the team's input on that one, naming is hard. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Renamed to |
||
|
||
|
||
def find_manifest_file(deploy_root: Path) -> Path: | ||
|
@@ -283,3 +292,33 @@ def find_version_info_in_manifest_file( | |
patch_name = version_info[patch_field] | ||
|
||
return version_name, patch_name | ||
|
||
|
||
def project_path_to_deploy_path( | ||
sfc-gh-bdufour marked this conversation as resolved.
Show resolved
Hide resolved
|
||
project_path: Path, created_files: ArtifactDeploymentMap | ||
) -> Path: | ||
"""Given a source path, returns the deploy destination path. This function assumes that a bundle was created before calling it.""" | ||
|
||
project_path = project_path.resolve() | ||
|
||
if project_path in created_files: | ||
return created_files[project_path] | ||
|
||
# Find the first parent directory that exists in created_files | ||
common_root = Path(project_path).resolve() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would simply |
||
while common_root: | ||
if common_root in created_files: | ||
break | ||
elif common_root.parent != common_root: | ||
common_root = common_root.parent | ||
else: | ||
raise FileNotFoundError(project_path) | ||
|
||
# Construct the target deploy path | ||
path_to_symlink = created_files[common_root] | ||
path_to_target = Path(project_path).relative_to(common_root) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: relative_path_to_target ? |
||
result = Path(path_to_symlink, path_to_target) | ||
|
||
if not result.exists(): | ||
raise FileNotFoundError(result) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why so opinionated here? This is a utility function to compute a mapping. It's odd to enforce that the computed path must exist, and makes the utility function generally less useful IMHO. |
||
return result |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
import logging | ||
from typing import Optional | ||
from pathlib import Path | ||
from typing import List, Optional | ||
|
||
import typer | ||
from snowflake.cli.api.cli_global_context import cli_context | ||
|
@@ -229,17 +230,44 @@ def app_teardown( | |
@app.command("deploy", requires_connection=True) | ||
@with_project_definition("native_app") | ||
def app_deploy( | ||
prune: Optional[bool] = typer.Option( | ||
default=None, | ||
help=f"""Controls whether files that exist only remotely will be deleted from the stage.""", | ||
sfc-gh-cgorrie marked this conversation as resolved.
Show resolved
Hide resolved
|
||
), | ||
recursive: Optional[bool] = typer.Option( | ||
None, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Personally I would use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I needed it to be |
||
"--recursive", | ||
"-r", | ||
help=f"""Controls whether the specified directories should be deployed including all of their contents.""", | ||
sfc-gh-cgorrie marked this conversation as resolved.
Show resolved
Hide resolved
|
||
), | ||
files: Optional[List[Path]] = typer.Argument( | ||
default=None, | ||
show_default=False, | ||
help=f"""Paths of the files, relative to the project root, to be uploaded to a stage. [default: sync all local changes to the stage]""", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Paths, relative to the the project root, of files you want to upload to a stage. If unspecified, the command syncs all local changes to the stage. |
||
), | ||
**options, | ||
) -> CommandResult: | ||
""" | ||
Creates an application package in your Snowflake account and syncs the local changes to the stage without creating or updating the application. | ||
Running this command with no arguments at all is a shorthand for "snow app deploy --prune --recursive". | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ... at all, as in I think we use `` for commands. The scripts then convert it to the proper RST code format. |
||
""" | ||
if files is None: | ||
files = [] | ||
if prune is None and recursive is None and len(files) == 0: | ||
prune = True | ||
recursive = True | ||
else: | ||
if prune is None: | ||
prune = False | ||
if recursive is None: | ||
recursive = False | ||
|
||
manager = NativeAppManager( | ||
project_definition=cli_context.project_definition, | ||
project_root=cli_context.project_root, | ||
) | ||
|
||
manager.build_bundle() | ||
manager.deploy() | ||
created_files = manager.build_bundle() | ||
manager.deploy(prune, recursive, files, created_files) | ||
|
||
return MessageResult(f"Deployed successfully.") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
from __future__ import annotations | ||
|
||
import os | ||
from abc import ABC, abstractmethod | ||
from functools import cached_property | ||
from pathlib import Path | ||
|
@@ -23,8 +24,11 @@ | |
from snowflake.cli.api.sql_execution import SqlExecutionMixin | ||
from snowflake.cli.plugins.connection.util import make_snowsight_url | ||
from snowflake.cli.plugins.nativeapp.artifacts import ( | ||
ArtifactDeploymentMap, | ||
ArtifactMapping, | ||
build_bundle, | ||
project_path_to_deploy_path, | ||
resolve_without_follow, | ||
translate_artifact, | ||
) | ||
from snowflake.cli.plugins.nativeapp.constants import ( | ||
|
@@ -45,6 +49,7 @@ | |
) | ||
from snowflake.cli.plugins.stage.diff import ( | ||
DiffResult, | ||
filter_from_diff, | ||
stage_diff, | ||
sync_local_diff_with_stage, | ||
) | ||
|
@@ -100,6 +105,28 @@ def ensure_correct_owner(row: dict, role: str, obj_name: str) -> None: | |
raise UnexpectedOwnerError(obj_name, role, actual_owner) | ||
|
||
|
||
def _get_files_to_sync(paths_to_sync: List[Path], deploy_root: Path) -> List[str]: | ||
"""Takes a list of paths (files and directories), returning a list of all files recursively, stripping the path to deploy root.""" | ||
sfc-gh-cgorrie marked this conversation as resolved.
Show resolved
Hide resolved
|
||
paths = [] | ||
for path in paths_to_sync: | ||
if path.is_dir(): | ||
for current_dir, _dirs, files in os.walk(path): | ||
for file in files: | ||
deploy_path = Path(current_dir, file).relative_to(deploy_root) | ||
paths.append(str(deploy_path)) | ||
Comment on lines
+116
to
+117
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why convert back to str here? We could leave them as relative paths There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
else: | ||
paths.append(str(path.relative_to(deploy_root))) | ||
return paths | ||
|
||
|
||
def _verify_no_directories(paths_to_sync): | ||
for path in paths_to_sync: | ||
if path.is_dir(): | ||
raise ValueError( | ||
f"{path} is a directory. Add the -r flag to deploy directories." | ||
) | ||
|
||
|
||
class NativeAppCommandProcessor(ABC): | ||
@abstractmethod | ||
def process(self, *args, **kwargs): | ||
|
@@ -278,13 +305,20 @@ def verify_project_distribution( | |
return False | ||
return True | ||
|
||
def build_bundle(self) -> None: | ||
def build_bundle(self) -> ArtifactDeploymentMap: | ||
""" | ||
Populates the local deploy root from artifact sources. | ||
""" | ||
build_bundle(self.project_root, self.deploy_root, self.artifacts) | ||
|
||
def sync_deploy_root_with_stage(self, role: str) -> DiffResult: | ||
return build_bundle(self.project_root, self.deploy_root, self.artifacts) | ||
|
||
def sync_deploy_root_with_stage( | ||
self, | ||
role: str, | ||
prune: bool, | ||
recursive: bool, | ||
paths_to_sync: List[Path] = [], # relative to project root | ||
created_files: Optional[ArtifactDeploymentMap] = None, | ||
) -> DiffResult: | ||
""" | ||
Ensures that the files on our remote stage match the artifacts we have in | ||
the local filesystem. Returns the DiffResult used to make changes. | ||
|
@@ -310,6 +344,23 @@ def sync_deploy_root_with_stage(self, role: str) -> DiffResult: | |
% self.deploy_root | ||
) | ||
diff: DiffResult = stage_diff(self.deploy_root, self.stage_fqn) | ||
|
||
# If we are syncing specific files/directories, remove everything else from the diff | ||
if len(paths_to_sync) > 0: | ||
paths_to_sync = [resolve_without_follow(p) for p in paths_to_sync] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Generally not a fan of re-assigning incoming parameters |
||
if not recursive: | ||
_verify_no_directories(paths_to_sync) | ||
deploy_paths = [ | ||
project_path_to_deploy_path(p, created_files) for p in paths_to_sync | ||
] | ||
paths_to_keep = set( | ||
_get_files_to_sync(deploy_paths, self.deploy_root.resolve()) | ||
) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok, subjective, but the naming confuses me here... We have deploy_paths, paths_to_sync, paths_to_keep and files_to_sync. Can you replace the names with more descriptive options? I'm losing track of what's what. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Changed everything to |
||
filter_from_diff(diff, paths_to_keep, prune) | ||
|
||
if prune is False: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: if not prune? Feels odd to check for False explicitly |
||
diff.only_on_stage = [] | ||
|
||
cc.message(str(diff)) | ||
|
||
# Upload diff-ed files to application package stage | ||
|
@@ -435,7 +486,13 @@ def _apply_package_scripts(self) -> None: | |
err, role=self.package_role, warehouse=self.package_warehouse | ||
) | ||
|
||
def deploy(self) -> DiffResult: | ||
def deploy( | ||
self, | ||
prune: bool, | ||
recursive: bool, | ||
paths_to_sync: List[Path] = [], | ||
created_files: Optional[ArtifactDeploymentMap] = None, | ||
) -> DiffResult: | ||
"""app deploy process""" | ||
|
||
# 1. Create an empty application package, if none exists | ||
|
@@ -446,6 +503,8 @@ def deploy(self) -> DiffResult: | |
self._apply_package_scripts() | ||
|
||
# 3. Upload files from deploy root local folder to the above stage | ||
diff = self.sync_deploy_root_with_stage(self.package_role) | ||
diff = self.sync_deploy_root_with_stage( | ||
self.package_role, prune, recursive, paths_to_sync, created_files | ||
) | ||
|
||
return diff |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might be helpful to include the name of the option