diff --git a/src/snowflake/cli/plugins/nativeapp/commands.py b/src/snowflake/cli/plugins/nativeapp/commands.py index 2b5420d7fa..bdff1a2ebc 100644 --- a/src/snowflake/cli/plugins/nativeapp/commands.py +++ b/src/snowflake/cli/plugins/nativeapp/commands.py @@ -7,9 +7,17 @@ with_project_definition, ) from snowflake.cli.api.commands.snow_typer import SnowTyper -from snowflake.cli.api.output.types import CommandResult, MessageResult +from snowflake.cli.api.output.types import ( + CollectionResult, + CommandResult, + MessageResult, +) +from snowflake.cli.api.secure_path import SecurePath from snowflake.cli.plugins.nativeapp.common_flags import ForceOption, InteractiveOption -from snowflake.cli.plugins.nativeapp.init import nativeapp_init +from snowflake.cli.plugins.nativeapp.init import ( + OFFICIAL_TEMPLATES_GITHUB_URL, + nativeapp_init, +) from snowflake.cli.plugins.nativeapp.manager import NativeAppManager from snowflake.cli.plugins.nativeapp.policy import ( AllowAlwaysPolicy, @@ -20,7 +28,11 @@ from snowflake.cli.plugins.nativeapp.teardown_processor import ( NativeAppTeardownProcessor, ) -from snowflake.cli.plugins.nativeapp.utils import is_tty_interactive +from snowflake.cli.plugins.nativeapp.utils import ( + get_first_paragraph_from_markdown_file, + is_tty_interactive, + shallow_git_clone, +) from snowflake.cli.plugins.nativeapp.version.commands import app as versions_app app = SnowTyper( @@ -69,6 +81,39 @@ def app_init( ) +@app.command("list-templates", hidden=True) +def app_list_templates(**options) -> CommandResult: + """ + Prints information regarding the official templates that can be used with snow app init. + """ + with SecurePath.temporary_directory() as temp_path: + repo = shallow_git_clone(OFFICIAL_TEMPLATES_GITHUB_URL, temp_path.path) + + # Mark a directory as a template if a project definition jinja template is inside + template_directories = [ + entry.name + for entry in repo.head.commit.tree + if (temp_path / entry.name / "snowflake.yml.jinja").exists() + ] + + # get the template descriptions from the README.md in its directory + template_descriptions = [ + get_first_paragraph_from_markdown_file( + (temp_path / directory / "README.md").path + ) + for directory in template_directories + ] + + result = ( + {"template": directory, "description": description} + for directory, description in zip( + template_directories, template_descriptions + ) + ) + + return CollectionResult(result) + + @app.command("bundle", hidden=True) @with_project_definition("native_app") def app_bundle( diff --git a/src/snowflake/cli/plugins/nativeapp/init.py b/src/snowflake/cli/plugins/nativeapp/init.py index 4e7119e11c..c5b4a8af31 100644 --- a/src/snowflake/cli/plugins/nativeapp/init.py +++ b/src/snowflake/cli/plugins/nativeapp/init.py @@ -222,18 +222,10 @@ def _init_from_template( try: with SecurePath.temporary_directory() as temp_path: - from git import Repo from git import rmtree as git_rmtree + from snowflake.cli.plugins.nativeapp.utils import shallow_git_clone - # Clone the repository in the temporary directory with options. - repo = Repo.clone_from( - url=git_url, - to_path=temp_path.path, - filter=["tree:0"], - depth=1, - ) - # Close repo to avoid issues with permissions on Windows - repo.close() + shallow_git_clone(git_url, temp_path.path) if use_whole_repo_as_template: # the template is the entire git repository diff --git a/src/snowflake/cli/plugins/nativeapp/utils.py b/src/snowflake/cli/plugins/nativeapp/utils.py index 081e6542d5..9091142a6c 100644 --- a/src/snowflake/cli/plugins/nativeapp/utils.py +++ b/src/snowflake/cli/plugins/nativeapp/utils.py @@ -1,4 +1,7 @@ +from os import PathLike +from pathlib import Path from sys import stdin, stdout +from typing import Optional, Union def needs_confirmation(needs_confirm: bool, auto_yes: bool) -> bool: @@ -7,3 +10,58 @@ def needs_confirmation(needs_confirm: bool, auto_yes: bool) -> bool: def is_tty_interactive(): return stdin.isatty() and stdout.isatty() + + +def get_first_paragraph_from_markdown_file(file_path: Path) -> Optional[str]: + """ + Reads a Markdown file at the given file path and finds the first paragraph + + Parameters: + file_path (Path): Path to Markdown file + + Returns: + Optional[str]: the first paragraph as a string, or None + if no paragraph could be found + + Raises: + FileNotFoundError: if file_path to Markdown file does not exist + """ + if not file_path.exists(): + raise FileNotFoundError(file_path) + + with open(file_path, "r") as markdown_file: + paragraph_text = None + + for line in markdown_file: + stripped_line = line.strip() + if not stripped_line.startswith("#") and stripped_line: + paragraph_text = stripped_line + break + + return paragraph_text + + +def shallow_git_clone(url: Union[str, PathLike], to_path: Union[str, PathLike]): + """ + Performs a shallow clone of the repository at the provided url to the path specified + + Parameters: + url (str | PathLike): Valid git url. + to_path (str | PathLike): Path to which the repository should be cloned to. + + Returns: + Repo: the repository that was cloned + """ + from git import Repo + + # Clone the repository in the directory with options. + repo = Repo.clone_from( + url=url, + to_path=to_path, + filter=["tree:0"], + depth=1, + ) + # Close repo to avoid issues with permissions on Windows + repo.close() + + return repo diff --git a/tests/__snapshots__/test_help_messages.ambr b/tests/__snapshots__/test_help_messages.ambr index 30d2bd6296..d802f1d3d2 100644 --- a/tests/__snapshots__/test_help_messages.ambr +++ b/tests/__snapshots__/test_help_messages.ambr @@ -113,6 +113,31 @@ ╰──────────────────────────────────────────────────────────────────────────────╯ + ''' +# --- +# name: test_help_messages[app.list-templates] + ''' + + Usage: default app list-templates [OPTIONS] + + Prints information regarding the official templates that can be used with snow + app init. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --help -h Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Global configuration ───────────────────────────────────────────────────────╮ + │ --format [TABLE|JSON] Specifies the output format. │ + │ [default: TABLE] │ + │ --verbose -v Displays log entries for log levels `info` │ + │ and higher. │ + │ --debug Displays log entries for log levels `debug` │ + │ and higher; debug logs contains additional │ + │ information. │ + │ --silent Turns off intermediate output to console. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + ''' # --- # name: test_help_messages[app.open] diff --git a/tests/nativeapp/__snapshots__/test_commands.ambr b/tests/nativeapp/__snapshots__/test_commands.ambr index aca3cc4cbd..14f79b62f9 100644 --- a/tests/nativeapp/__snapshots__/test_commands.ambr +++ b/tests/nativeapp/__snapshots__/test_commands.ambr @@ -358,3 +358,28 @@ ''' # --- +# name: test_list_templates_no_options_success + ''' + +------------------------------------------------------------------------------+ + | template | description | + |------------------+-----------------------------------------------------------| + | basic | This is the basic project template for a Snowflake Native | + | | App project. It contains minimal code meant to help you | + | | set up your first application object in your account | + | | quickly. | + | streamlit-java | This is an example template for a Snowflake Native App | + | | project which demonstrates the use of Java extension code | + | | and adding Streamlit code. This template is meant to | + | | guide developers towards a possible project structure on | + | | the basis of functionality, as well as to indicate the | + | | contents of some common and useful files. | + | streamlit-python | This is an example template for a Snowflake Native App | + | | project which demonstrates the use of Python extension | + | | code and adding Streamlit code. This template is meant to | + | | guide developers towards a possible project structure on | + | | the basis of functionality, as well as to indicate the | + | | contents of some common and useful files. | + +------------------------------------------------------------------------------+ + + ''' +# --- diff --git a/tests/nativeapp/test_commands.py b/tests/nativeapp/test_commands.py index da5ffe2b00..d5ca04578f 100644 --- a/tests/nativeapp/test_commands.py +++ b/tests/nativeapp/test_commands.py @@ -109,3 +109,11 @@ def test_init_no_template_failure( assert result.exit_code == 1 assert result.output == snapshot + + +def test_list_templates_no_options_success(runner, temp_dir, snapshot): + args = ["app", "list-templates"] + result = runner.invoke(args) + + assert result.exit_code == 0 + assert result.output == snapshot diff --git a/tests/nativeapp/test_utils.py b/tests/nativeapp/test_utils.py new file mode 100644 index 0000000000..d69cb25efe --- /dev/null +++ b/tests/nativeapp/test_utils.py @@ -0,0 +1,77 @@ +from textwrap import dedent + +import pytest +from snowflake.cli.api.secure_path import SecurePath +from snowflake.cli.plugins.nativeapp.utils import get_first_paragraph_from_markdown_file + + +@pytest.mark.parametrize( + "file_content, expected_paragraph", + [ + ( + dedent( + """ + ## Introduction + + This is an example template for a Snowflake Native App project which demonstrates the use of Python extension code and adding Streamlit code. This template is meant to guide developers towards a possible project structure on the basis of functionality, as well as to indicate the contents of some common and useful files. + + Since this template contains Python files only, you do not need to perform any additional steps to build the source code. You can directly go to the next section. However, if there were any source code that needed to be built, you must manually perform the build steps here before proceeding to the next section. + + Similarly, you can also use your own build steps for any other languages supported by Snowflake that you wish to write your code in. For more information on supported languages, visit [docs](https://docs.snowflake.com/en/developer-guide/stored-procedures-vs-udfs#label-sp-udf-languages). + """ + ), + "This is an example template for a Snowflake Native App project which demonstrates the use of Python extension code and adding Streamlit code. This template is meant to guide developers towards a possible project structure on the basis of functionality, as well as to indicate the contents of some common and useful files.", + ), + ( + "Similarly, you can also use your own build steps for any other languages supported by Snowflake that you wish to write your code in. For more information on supported languages, visit [docs](https://docs.snowflake.com/en/developer-guide/stored-procedures-vs-udfs#label-sp-udf-languages).", + "Similarly, you can also use your own build steps for any other languages supported by Snowflake that you wish to write your code in. For more information on supported languages, visit [docs](https://docs.snowflake.com/en/developer-guide/stored-procedures-vs-udfs#label-sp-udf-languages).", + ), + ], +) +def test_get_first_paragraph_from_markdown_file_with_valid_path_and_paragraph_content( + file_content, expected_paragraph +): + with SecurePath.temporary_directory() as temp_path: + temp_readme_path = (temp_path / "README.md").path + + with open(temp_readme_path, "w+") as temp_readme_file: + temp_readme_file.write(file_content) + + actual_paragraph = get_first_paragraph_from_markdown_file(temp_readme_path) + + assert actual_paragraph == expected_paragraph + + +@pytest.mark.parametrize( + "file_content", + [ + dedent( + """ + # Just some Headings + + ## And some whitespace. + """ + ), + "", + ], +) +def test_get_first_paragraph_from_markdown_file_with_valid_path_and_no_paragraph_content( + file_content, +): + with SecurePath.temporary_directory() as temp_path: + temp_readme_path = (temp_path / "README.md").path + + with open(temp_readme_path, "w+") as temp_readme_file: + temp_readme_file.write(file_content) + + result = get_first_paragraph_from_markdown_file(temp_readme_path) + + assert result is None + + +def test_get_first_paragraph_from_markdown_file_with_invalid_path(): + with SecurePath.temporary_directory() as temp_path: + temp_readme_path = (temp_path / "README.md").path + + with pytest.raises(FileNotFoundError): + get_first_paragraph_from_markdown_file(temp_readme_path)