Skip to content
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

Snow 1761248 introduce notebook entity #2007

Merged
merged 37 commits into from
Feb 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
5464f82
IN PROGRESS
sfc-gh-pczajka Jan 16, 2025
a1e1967
IN PROGRESS
sfc-gh-pczajka Jan 17, 2025
6005d36
IN PROGRESS
sfc-gh-pczajka Jan 17, 2025
8560ac0
notebook deploy
sfc-gh-pczajka Jan 20, 2025
212ea38
check project definition version
sfc-gh-pczajka Jan 20, 2025
8a4b172
Different stage default paths for different notebooks
sfc-gh-pczajka Jan 20, 2025
29437cc
unit tests
sfc-gh-pczajka Jan 20, 2025
2f1b7cd
integration tests
sfc-gh-pczajka Jan 20, 2025
78c1880
update release notes
sfc-gh-pczajka Jan 20, 2025
d5e7ef3
Merge branch 'main' into SNOW-1761248-introduce-notebook-ity
sfc-gh-pczajka Jan 20, 2025
667bd9d
Add unit test
sfc-gh-pczajka Jan 21, 2025
bc2c2a5
Merge branch 'main' into SNOW-1761248-introduce-notebook-entity
sfc-gh-pczajka Jan 21, 2025
78e7a58
Update RELEASE-NOTES.md
sfc-gh-pczajka Jan 24, 2025
740a15b
Update src/snowflake/cli/_plugins/notebook/commands.py
sfc-gh-pczajka Jan 24, 2025
16b6b6c
improve help messages
sfc-gh-pczajka Jan 21, 2025
2c8ec66
snapshot update
sfc-gh-pczajka Jan 24, 2025
d75c216
remove if-not-exists
sfc-gh-pczajka Jan 24, 2025
4d7e437
split logic into actions
sfc-gh-pczajka Jan 24, 2025
86f8771
IN PROGRESS
sfc-gh-pczajka Jan 24, 2025
fd4a6ba
integration tests
sfc-gh-pczajka Jan 27, 2025
3d7a269
fix help message
sfc-gh-pczajka Jan 27, 2025
0adb41f
unit tests
sfc-gh-pczajka Jan 27, 2025
a48f8f9
Add complimentary actions
sfc-gh-pczajka Jan 27, 2025
bb665a6
Merge branch 'main' into SNOW-1761248-introduce-notebook-entity
sfc-gh-pczajka Jan 27, 2025
ebf8d07
update release notes
sfc-gh-pczajka Jan 27, 2025
73f2108
Windows test fix
sfc-gh-pczajka Jan 27, 2025
1aed9fc
Add artifacts to notebook
sfc-gh-pczajka Jan 29, 2025
fe9d572
small SPCS refactor
sfc-gh-pczajka Jan 29, 2025
ba670ed
fix tests
sfc-gh-pczajka Jan 29, 2025
168dc39
Merge branch 'main' into SNOW-1761248-introduce-notebook-entity
sfc-gh-pczajka Jan 29, 2025
a1aaf6d
Merge branch 'main' into SNOW-1761248-introduce-notebook-entity
sfc-gh-pczajka Jan 30, 2025
48c5a1c
fixes
sfc-gh-pczajka Jan 30, 2025
aa41f4b
fix tests
sfc-gh-pczajka Jan 30, 2025
294067c
debug
sfc-gh-pczajka Jan 30, 2025
011c0b4
Add test to fix
sfc-gh-pczajka Jan 30, 2025
394c7af
fix console steps
sfc-gh-pczajka Jan 30, 2025
98f2b2e
Merge branch 'main' into SNOW-1761248-introduce-notebook-entity
sfc-gh-pczajka Jan 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
* Added image repository model in snowflake.yml.
* Added `snow spcs service deploy` command.
* Added notebooks to `snow object` commands.
* Added `snow notebook deploy` command that allows creating a notebook using local file.

## Fixes and improvements

Expand Down
57 changes: 55 additions & 2 deletions src/snowflake/cli/_plugins/notebook/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,28 @@
import logging

import typer
from click import UsageError
from snowflake.cli._plugins.notebook.manager import NotebookManager
from snowflake.cli._plugins.notebook.notebook_entity_model import NotebookEntityModel
from snowflake.cli._plugins.notebook.types import NotebookStagePath
from snowflake.cli.api.commands.flags import identifier_argument
from snowflake.cli._plugins.workspace.manager import WorkspaceManager
from snowflake.cli.api.cli_global_context import get_cli_context
from snowflake.cli.api.commands.decorators import (
with_project_definition,
)
from snowflake.cli.api.commands.flags import (
ReplaceOption,
entity_argument,
identifier_argument,
)
from snowflake.cli.api.commands.snow_typer import SnowTyperFactory
from snowflake.cli.api.commands.utils import get_entity_for_operation
from snowflake.cli.api.entities.common import EntityActions
from snowflake.cli.api.identifiers import FQN
from snowflake.cli.api.output.types import MessageResult
from snowflake.cli.api.output.types import (
CommandResult,
MessageResult,
)
from typing_extensions import Annotated

app = SnowTyperFactory(
Expand Down Expand Up @@ -84,3 +100,40 @@ def create(
notebook_file=notebook_file,
)
return MessageResult(message=notebook_url)


@app.command(requires_connection=True)
@with_project_definition()
def deploy(
entity_id: str = entity_argument("notebook"),
replace: bool = ReplaceOption(
help="Replace notebook object if it already exists.",
),
**options,
) -> CommandResult:
"""Uploads a notebook and required files to a stage and creates a Snowflake notebook."""
cli_context = get_cli_context()
pd = cli_context.project_definition
if not pd.meets_version_requirement("2"):
raise UsageError(
"This command requires project definition of version at least 2."
)

notebook: NotebookEntityModel = get_entity_for_operation(
cli_context=cli_context,
entity_id=entity_id,
project_definition=pd,
entity_type="notebook",
)
ws = WorkspaceManager(
project_definition=cli_context.project_definition,
project_root=cli_context.project_root,
)
notebook_url = ws.perform_action(
notebook.entity_id,
EntityActions.DEPLOY,
replace=replace,
)
return MessageResult(
f"Notebook successfully deployed and available under {notebook_url}"
)
2 changes: 1 addition & 1 deletion src/snowflake/cli/_plugins/notebook/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@
from click.exceptions import ClickException


class NotebookStagePathError(ClickException):
class NotebookFilePathError(ClickException):
def __init__(self, path: str):
super().__init__(f"Cannot extract notebook file name from {path=}")
6 changes: 3 additions & 3 deletions src/snowflake/cli/_plugins/notebook/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from textwrap import dedent

from snowflake.cli._plugins.connection.util import make_snowsight_url
from snowflake.cli._plugins.notebook.exceptions import NotebookStagePathError
from snowflake.cli._plugins.notebook.exceptions import NotebookFilePathError
from snowflake.cli._plugins.notebook.types import NotebookStagePath
from snowflake.cli.api.cli_global_context import get_cli_context
from snowflake.cli.api.identifiers import FQN
Expand All @@ -40,11 +40,11 @@ def get_url(self, notebook_name: FQN):
def parse_stage_as_path(notebook_file: str) -> Path:
"""Parses notebook file path to pathlib.Path."""
if not notebook_file.endswith(".ipynb"):
raise NotebookStagePathError(notebook_file)
raise NotebookFilePathError(notebook_file)
# we don't perform any operations on the path, so we don't need to differentiate git repository paths
stage_path = StagePath.from_stage_str(notebook_file)
if len(stage_path.parts) < 1:
raise NotebookStagePathError(notebook_file)
raise NotebookFilePathError(notebook_file)

return stage_path

Expand Down
115 changes: 115 additions & 0 deletions src/snowflake/cli/_plugins/notebook/notebook_entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import functools
from textwrap import dedent

from click import ClickException
from snowflake.cli._plugins.connection.util import make_snowsight_url
from snowflake.cli._plugins.notebook.notebook_entity_model import NotebookEntityModel
from snowflake.cli._plugins.notebook.notebook_project_paths import NotebookProjectPaths
from snowflake.cli._plugins.stage.manager import StageManager
from snowflake.cli._plugins.workspace.context import ActionContext
from snowflake.cli.api.artifacts.utils import bundle_artifacts
from snowflake.cli.api.cli_global_context import get_cli_context
from snowflake.cli.api.console.console import cli_console
from snowflake.cli.api.entities.common import EntityBase
from snowflake.cli.api.stage_path import StagePath
from snowflake.connector import ProgrammingError
from snowflake.connector.cursor import SnowflakeCursor

_DEFAULT_NOTEBOOK_STAGE_NAME = "@notebooks"


class NotebookEntity(EntityBase[NotebookEntityModel]):
"""
A notebook.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed 😺

"""

@functools.cached_property
def _stage_path(self) -> StagePath:
stage_path = self.model.stage_path
if stage_path is None:
stage_path = f"{_DEFAULT_NOTEBOOK_STAGE_NAME}/{self.fqn.name}"
return StagePath.from_stage_str(stage_path)

@functools.cached_property
def _project_paths(self):
return NotebookProjectPaths(get_cli_context().project_root)

def _object_exists(self) -> bool:
# currently notebook objects are not supported by object manager - re-implementing "exists"
try:
self.action_describe()
return True
except ProgrammingError:
return False

def _upload_artifacts(self):
stage_fqn = self._stage_path.stage_fqn
stage_manager = StageManager()
cli_console.step(f"Creating stage {stage_fqn} if not exists")
stage_manager.create(fqn=stage_fqn)

cli_console.step("Uploading artifacts")

# creating bundle map to handle glob patterns logic
bundle_map = bundle_artifacts(self._project_paths, self.model.artifacts)
for absolute_src, absolute_dest in bundle_map.all_mappings(
absolute=True, expand_directories=True
):
artifact_stage_path = self._stage_path / (
absolute_dest.relative_to(self._project_paths.bundle_root).parent
)
stage_manager.put(
local_path=absolute_src, stage_path=artifact_stage_path, overwrite=True
)

def get_create_sql(self, replace: bool) -> str:
main_file_stage_path = self._stage_path / (
self.model.notebook_file.absolute().relative_to(
self._project_paths.project_root
)
)
create_str = "CREATE OR REPLACE" if replace else "CREATE"
return dedent(
f"""
{create_str} NOTEBOOK {self.fqn.sql_identifier}
FROM '{main_file_stage_path.stage_with_at}'
QUERY_WAREHOUSE = '{self.model.query_warehouse}'
MAIN_FILE = '{main_file_stage_path.path}';
// Cannot use IDENTIFIER(...)
ALTER NOTEBOOK {self.fqn.identifier} ADD LIVE VERSION FROM LAST;
"""
)

def action_describe(self) -> SnowflakeCursor:
return self._sql_executor.execute_query(self.get_describe_sql())

def action_create(self, replace: bool) -> str:
self._sql_executor.execute_query(self.get_create_sql(replace))
return make_snowsight_url(
self._conn,
f"/#/notebooks/{self.fqn.using_connection(self._conn).url_identifier}",
)

def action_deploy(
self,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we have action_deploy - we probably should have an action_drop

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It currently won't be used by any command - should we implement it in this PR?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we introduce an entity- i think we should at least make some dummy methods to show, what is left to implement

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added placeholders

action_ctx: ActionContext,
replace: bool,
*args,
**kwargs,
) -> str:
if self._object_exists():
if not replace:
raise ClickException(
f"Notebook {self.fqn.name} already exists. Consider using --replace."
)
with cli_console.phase(f"Uploading artifacts to {self._stage_path}"):
self._upload_artifacts()
with cli_console.phase(f"Creating notebook {self.fqn}"):
return self.action_create(replace=replace)

# complementary actions, currently not used - to be implemented in future
def action_drop(self, *args, **kwargs):
raise ClickException("action DROP not supported by NOTEBOOK entity")

def action_teardown(self, *args, **kwargs):
raise ClickException("action TEARDOWN not supported by NOTEBOOK entity")
30 changes: 30 additions & 0 deletions src/snowflake/cli/_plugins/notebook/notebook_entity_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from __future__ import annotations

from pathlib import Path
from typing import Literal, Optional

from pydantic import Field, model_validator
from snowflake.cli._plugins.notebook.exceptions import NotebookFilePathError
from snowflake.cli.api.project.schemas.entities.common import (
EntityModelBaseWithArtifacts,
)
from snowflake.cli.api.project.schemas.updatable_model import (
DiscriminatorField,
)


class NotebookEntityModel(EntityModelBaseWithArtifacts):
type: Literal["notebook"] = DiscriminatorField() # noqa: A003
stage_path: Optional[str] = Field(
title="Stage directory in which the notebook file will be stored", default=None
)
notebook_file: Path = Field(title="Notebook file")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we consider using artifact approach with notbeook_file being a pointer (similar to streamlit main file)? In this way you can upload more than one file, for example .py file with scripts or a .csv data. WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, added

query_warehouse: str = Field(title="Snowflake warehouse to execute the notebook")

@model_validator(mode="after")
def validate_notebook_file(self):
if not self.notebook_file.exists():
raise ValueError(f"Notebook file {self.notebook_file} does not exist")
if self.notebook_file.suffix.lower() != ".ipynb":
raise NotebookFilePathError(str(self.notebook_file))
return self
15 changes: 15 additions & 0 deletions src/snowflake/cli/_plugins/notebook/notebook_project_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from dataclasses import dataclass
from pathlib import Path

from snowflake.cli.api.project.project_paths import ProjectPaths, bundle_root


@dataclass
class NotebookProjectPaths(ProjectPaths):
"""
This class allows you to manage files paths related to given project.
"""

@property
def bundle_root(self) -> Path:
return bundle_root(self.project_root, "notebook")
3 changes: 3 additions & 0 deletions src/snowflake/cli/_plugins/notebook/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from pathlib import Path

NotebookStagePath = str
NotebookLocalPath = Path
31 changes: 8 additions & 23 deletions src/snowflake/cli/_plugins/spcs/services/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import json
import time
from datetime import datetime
from pathlib import Path, PurePosixPath
from pathlib import Path
from typing import List, Optional

import yaml
Expand All @@ -41,13 +41,13 @@
ServiceProjectPaths,
)
from snowflake.cli._plugins.stage.manager import StageManager
from snowflake.cli.api.artifacts.bundle_map import BundleMap
from snowflake.cli.api.artifacts.utils import symlink_or_copy
from snowflake.cli.api.artifacts.utils import bundle_artifacts
from snowflake.cli.api.constants import DEFAULT_SIZE_LIMIT_MB, ObjectType
from snowflake.cli.api.identifiers import FQN
from snowflake.cli.api.project.schemas.entities.common import Artifacts
from snowflake.cli.api.secure_path import SecurePath
from snowflake.cli.api.sql_execution import SqlExecutionMixin
from snowflake.cli.api.stage_path import StagePath
from snowflake.connector.cursor import DictCursor, SnowflakeCursor
from snowflake.connector.errors import ProgrammingError

Expand Down Expand Up @@ -181,31 +181,16 @@ def _upload_artifacts(
if not artifacts:
raise ValueError("Service needs to have artifacts to deploy")

bundle_map = BundleMap(
project_root=service_project_paths.project_root,
deploy_root=service_project_paths.bundle_root,
)
for artifact in artifacts:
bundle_map.add(artifact)

service_project_paths.remove_up_bundle_root()
for (absolute_src, absolute_dest) in bundle_map.all_mappings(
bundle_map = bundle_artifacts(service_project_paths, artifacts)
for absolute_src, absolute_dest in bundle_map.all_mappings(
absolute=True, expand_directories=True
):
# We treat the bundle/service root as deploy root
symlink_or_copy(
absolute_src,
absolute_dest,
deploy_root=service_project_paths.bundle_root,
)
stage_path = (
PurePosixPath(absolute_dest)
.relative_to(service_project_paths.bundle_root)
.parent
stage_path = StagePath.from_stage_str(stage) / (
absolute_dest.relative_to(service_project_paths.bundle_root).parent
)
full_stage_path = f"{stage}/{stage_path}".rstrip("/")
stage_manager.put(
local_path=absolute_dest, stage_path=full_stage_path, overwrite=True
local_path=absolute_dest, stage_path=stage_path, overwrite=True
)

def execute_job(
Expand Down
31 changes: 31 additions & 0 deletions src/snowflake/cli/api/artifacts/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
import os
from pathlib import Path

from snowflake.cli.api.artifacts.bundle_map import BundleMap
from snowflake.cli.api.artifacts.common import NotInDeployRootError
from snowflake.cli.api.project.project_paths import ProjectPaths
from snowflake.cli.api.project.schemas.entities.common import Artifacts
from snowflake.cli.api.secure_path import SecurePath
from snowflake.cli.api.utils.path_utils import delete, resolve_without_follow

Expand Down Expand Up @@ -49,3 +52,31 @@ def symlink_or_copy(src: Path, dst: Path, deploy_root: Path) -> None:
dst=absolute_file_in_deploy,
deploy_root=deploy_root,
)


def bundle_artifacts(project_paths: ProjectPaths, artifacts: Artifacts) -> BundleMap:
"""
Creates a bundle directory (project_paths.bundle_root) with all artifacts (using symlink_or_copy function above).
Previous contents of the directory are deleted.

Returns a BundleMap containing the mapping between artifacts and their location in bundle directory.
"""
bundle_map = BundleMap(
project_root=project_paths.project_root,
deploy_root=project_paths.bundle_root,
)
for artifact in artifacts:
bundle_map.add(artifact)

project_paths.remove_up_bundle_root()
for absolute_src, absolute_dest in bundle_map.all_mappings(
absolute=True, expand_directories=True
):
# We treat the bundle root as deploy root
symlink_or_copy(
absolute_src,
absolute_dest,
deploy_root=project_paths.bundle_root,
)

return bundle_map
Loading