diff --git a/CHANGELOG.md b/CHANGELOG.md index a3fe12412..b7bc07bc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +### Bug fixes + +* CLI command `shiny create`... (#965) + * has added a `-d`/`--dir` flag for saving to a specific output directory + * will raise an error if if will overwrite existing files + * prompt users to install `requirements.txt` +* Fixed `js-react` template build error. (#965) + ## [0.6.1.1] - 2023-12-22 diff --git a/README.md b/README.md index 3411b50c2..346c408ad 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -Shiny for Python -================ +# Shiny for Python [![Release](https://img.shields.io/github/v/release/rstudio/py-shiny)](https://img.shields.io/github/v/release/rstudio/py-shiny) [![Build status](https://img.shields.io/github/actions/workflow/status/rstudio/py-shiny/pytest.yaml?branch=main)](https://img.shields.io/github/actions/workflow/status/rstudio/py-shiny/pytest.yaml?branch=main) @@ -10,13 +9,13 @@ Shiny for Python is the best way to build fast, beautiful web applications in Py To learn more about Shiny see the [Shiny for Python website](https://shiny.posit.co/py/). If you're new to the framework we recommend these resources: -- How [Shiny is different](https://posit.co/blog/why-shiny-for-python/) from Dash and Streamlit. +- How [Shiny is different](https://posit.co/blog/why-shiny-for-python/) from Dash and Streamlit. -- How [reactive programming](https://shiny.posit.co/py/docs/reactive-programming.html) can help you build better applications. +- How [reactive programming](https://shiny.posit.co/py/docs/reactive-programming.html) can help you build better applications. -- How to [use modules](https://shiny.posit.co/py/docs/workflow-modules.html) to efficiently develop large applications. +- How to [use modules](https://shiny.posit.co/py/docs/workflow-modules.html) to efficiently develop large applications. -- Hosting applications for free on [shinyapps.io](https://shiny.posit.co/py/docs/deploy.html#deploy-to-shinyapps.io-cloud-hosting), [Hugging Face](https://shiny.posit.co/blog/posts/shiny-on-hugging-face/), or [Shinylive](https://shiny.posit.co/py/docs/shinylive.html). +- Hosting applications for free on [shinyapps.io](https://shiny.posit.co/py/docs/deploy.html#deploy-to-shinyapps.io-cloud-hosting), [Hugging Face](https://shiny.posit.co/blog/posts/shiny-on-hugging-face/), or [Shinylive](https://shiny.posit.co/py/docs/shinylive.html). ## Join the conversation @@ -26,24 +25,19 @@ If you have questions about Shiny for Python, or want to help us decide what to To get started with shiny follow the [installation instructions](https://shiny.posit.co/py/docs/install.html) or just install it from pip. -``` sh +```sh pip install shiny ``` To install the latest development version: -``` sh +```sh # First install htmltools, then shiny pip install https://github.com/posit-dev/py-htmltools/tarball/main pip install https://github.com/posit-dev/py-shiny/tarball/main ``` -You can create and run your first application with: - -``` -shiny create . -shiny run app.py --reload -``` +You can create and run your first application with `shiny create`, the CLI will ask you which template you would like to use. You can either run the app with the Shiny extension, or call `shiny run app.py --reload --launch-browser`. ## Development @@ -51,13 +45,13 @@ API documentation for the `main` branch of Shiny: https://posit-dev.github.io/py If you want to do development on Shiny for Python: -``` sh +```sh pip install -e ".[dev,test]" ``` Additionally, you can install pre-commit hooks which will automatically reformat and lint the code when you make a commit: -``` sh +```sh pre-commit install # To disable: diff --git a/shiny/_custom_component_template_questions.py b/shiny/_custom_component_template_questions.py index 34ebcd3e2..00e64e4b0 100644 --- a/shiny/_custom_component_template_questions.py +++ b/shiny/_custom_component_template_questions.py @@ -1,10 +1,27 @@ import re +from importlib import util from pathlib import Path from prompt_toolkit.document import Document from questionary import ValidationError, Validator +def is_existing_module(name: str) -> bool: + """ + Check if a module name can be imported, which indicates that it is either + a standard module name, or the name of an installed module. + In either case the new module would probably cause a name conflict. + """ + try: + spec = util.find_spec(name) + if spec is not None: + return True + else: + return False + except ImportError: + return False + + def is_pep508_identifier(name: str): """ Checks if a package name is a PEP 508 identifier. @@ -65,6 +82,14 @@ def validate(self, document: Document): cursor_position=len(name), ) + # Using the name of an existing package causes an import error + + if is_existing_module(name): + raise ValidationError( + message="Package already installed in your current environment.", + cursor_position=len(name), + ) + def update_component_name_in_template(template_dir: Path, new_component_name: str): """ diff --git a/shiny/_main.py b/shiny/_main.py index 9a9dc659d..acdca39d7 100644 --- a/shiny/_main.py +++ b/shiny/_main.py @@ -513,18 +513,38 @@ def try_import_module(module: str) -> Optional[types.ModuleType]: "-g", help="The GitHub URL of the template sub-directory. For example https://github.com/posit-dev/py-shiny-templates/tree/main/dashboard", ) +@click.option( + "--dir", + "-d", + help="The destination directory, you will be prompted if this is not provided.", +) +@click.option( + "--package-name", + help=""" + If you are using one of the JavaScript component templates, + you can use this flag to specify the name of the resulting package without being prompted. + """, +) def create( template: Optional[str] = None, mode: Optional[str] = None, github: Optional[str] = None, + dir: Optional[str | Path] = None, + package_name: Optional[str] = None, ) -> None: from ._template_utils import template_query, use_git_template + if github is not None and template is not None: + raise click.UsageError("You cannot provide both --github and --template") + + if isinstance(dir, str): + dir = Path(dir) + if github is not None: - use_git_template(github, mode) + use_git_template(github, mode, dir) return - template_query(template, mode) + template_query(template, mode, dir, package_name) @main.command( diff --git a/shiny/_template_utils.py b/shiny/_template_utils.py index e2fb4e9b4..d38d47cbe 100644 --- a/shiny/_template_utils.py +++ b/shiny/_template_utils.py @@ -40,7 +40,12 @@ def choice_from_dict(choice_dict: dict[str, str]) -> list[Choice]: return [Choice(title=key, value=value) for key, value in choice_dict.items()] -def template_query(question_state: Optional[str] = None, mode: Optional[str] = None): +def template_query( + question_state: Optional[str] = None, + mode: Optional[str] = None, + dest_dir: Optional[Path] = None, + package_name: Optional[str] = None, +): """ This will initiate a CLI query which will ask the user which template they would like. If called without arguments this function will start from the top level and ask which @@ -69,12 +74,12 @@ def template_query(question_state: Optional[str] = None, mode: Optional[str] = N if template is None or template == "cancel": sys.exit(1) elif template == "js-component": - js_component_questions() + js_component_questions(dest_dir=dest_dir, package_name=package_name) return elif template in package_template_choices.values(): - js_component_questions(template) + js_component_questions(template, dest_dir=dest_dir, package_name=package_name) else: - app_template_questions(template, mode) + app_template_questions(template, mode, dest_dir=dest_dir) def download_and_extract_zip(url: str, temp_dir: Path): @@ -92,7 +97,9 @@ def download_and_extract_zip(url: str, temp_dir: Path): zip_file.extractall(temp_dir) -def use_git_template(url: str, mode: Optional[str] = None): +def use_git_template( + url: str, mode: Optional[str] = None, dest_dir: Optional[Path] = None +): # Github requires that we download the whole repository, so we need to # download and unzip the repo, then navigate to the subdirectory. @@ -116,13 +123,14 @@ def use_git_template(url: str, mode: Optional[str] = None): directory = repo_name + "-" + branch_name path = temp_dir / directory / subdirectory - return app_template_questions(mode=mode, template_dir=path) + return app_template_questions(mode=mode, template_dir=path, dest_dir=dest_dir) def app_template_questions( template: Optional[str] = None, mode: Optional[str] = None, template_dir: Optional[Path] = None, + dest_dir: Optional[Path] = None, ): if template_dir is None: if template is None: @@ -154,20 +162,10 @@ def app_template_questions( template_query() return - appdir = questionary.path( - "Enter destination directory:", - default=build_path_string(""), - only_directories=True, - ).ask() - - if appdir is None: - sys.exit(1) - - if appdir == ".": - appdir = build_path_string(template_dir.name) + dest_dir = directory_prompt(template_dir, dest_dir) app_dir = copy_template_files( - Path(appdir), + dest_dir, template_dir=template_dir, express_available=express_available, mode=mode, @@ -175,9 +173,14 @@ def app_template_questions( print(f"Created Shiny app at {app_dir}") print(f"Next steps open and edit the app file: {app_dir}/app.py") + print("You may need to install packages with: `pip install -r requirements.txt`") -def js_component_questions(component_type: Optional[str] = None): +def js_component_questions( + component_type: Optional[str] = None, + dest_dir: Optional[Path] = None, + package_name: Optional[str] = None, +): """ Hand question branch for the custom js templates. This should handle the entire rest of the question flow and is responsible for placing files etc. Currently it repeats @@ -202,40 +205,33 @@ def js_component_questions(component_type: Optional[str] = None): if component_type is None or component_type == "cancel": sys.exit(1) - # As what the user wants the name of their component to be - component_name = questionary.text( - "What do you want to name your component?", - instruction="Name must be dash-delimited and all lowercase. E.g. 'my-component-name'", - validate=ComponentNameValidator, - ).ask() - - if component_name is None: - sys.exit(1) + # Ask what the user wants the name of their component to be + if package_name is None: + package_name = questionary.text( + "What do you want to name your component?", + instruction="Name must be dash-delimited and all lowercase. E.g. 'my-component-name'", + validate=ComponentNameValidator, + ).ask() - appdir = questionary.path( - "Enter destination directory:", - default=build_path_string(component_name), - only_directories=True, - ).ask() + if package_name is None: + sys.exit(1) - if appdir is None: - sys.exit(1) + template_dir = ( + Path(__file__).parent / "templates/package-templates" / component_type + ) - if appdir == ".": - appdir = build_path_string(component_type) + dest_dir = directory_prompt(template_dir, dest_dir) app_dir = copy_template_files( - Path(appdir), - template_dir=Path(__file__).parent - / "templates/package-templates" - / component_type, + dest_dir, + template_dir=template_dir, express_available=False, mode=None, ) # Print messsage saying we're building the component - print(f"Setting up {component_name} component package...") - update_component_name_in_template(app_dir, component_name) + print(f"Setting up {package_name} component package...") + update_component_name_in_template(app_dir, package_name) print("\nNext steps:") print(f"- Run `cd {app_dir}` to change into the new directory") @@ -245,6 +241,27 @@ def js_component_questions(component_type: Optional[str] = None): print("- Open and run the example app in the `example-app` directory") +def directory_prompt( + template_dir: Path, dest_dir: Optional[Path | str | None] = None +) -> Path: + if dest_dir is not None: + return Path(dest_dir) + + app_dir = questionary.path( + "Enter destination directory:", + default=build_path_string(""), + only_directories=True, + ).ask() + + if app_dir is None: + sys.exit(1) + + if app_dir == ".": + app_dir = build_path_string(template_dir.name) + + return Path(app_dir) + + def build_path_string(*path: str): """ Build a path string that is valid for the current OS @@ -258,9 +275,14 @@ def copy_template_files( express_available: bool, mode: Optional[str] = None, ): - duplicate_files = [ - file.name for file in template_dir.iterdir() if (app_dir / file.name).exists() - ] + files_to_check = [file.name for file in template_dir.iterdir()] + + if "__pycache__" in files_to_check: + files_to_check.remove("__pycache__") + + files_to_check.append("app.py") + + duplicate_files = [file for file in files_to_check if (app_dir / file).exists()] if any(duplicate_files): err_files = ", ".join(['"' + file + '"' for file in duplicate_files]) @@ -276,7 +298,8 @@ def copy_template_files( if item.is_file(): shutil.copy(item, app_dir / item.name) else: - shutil.copytree(item, app_dir / item.name) + if item.name != "__pycache__": + shutil.copytree(item, app_dir / item.name) def rename_unlink(file_to_rename: str, file_to_delete: str, dir: Path = app_dir): (dir / file_to_rename).rename(dir / "app.py") diff --git a/shiny/templates/package-templates/js-react/srcts/index.tsx b/shiny/templates/package-templates/js-react/srcts/index.tsx index 4698b8219..1b35e7257 100644 --- a/shiny/templates/package-templates/js-react/srcts/index.tsx +++ b/shiny/templates/package-templates/js-react/srcts/index.tsx @@ -1,12 +1,15 @@ import { SketchPicker } from "react-color"; import React from "react"; -import { makeReactInput, makeReactOutput } from "@shiny-helpers/react"; +import { + makeReactInput, + makeReactOutput, +} from "@posit-dev/shiny-bindings-react"; // Generates a new input binding that renders the supplied react component // into the root of the webcomponent. makeReactInput({ - tagName: "custom-component-input", + name: "custom-component-input", initialValue: "#fff", renderComp: ({ initialValue, onNewValue }) => ( ({ - tagName: "custom-component-output", + name: "custom-component-output", renderComp: ({ value }) => (