diff --git a/pynecone/constants.py b/pynecone/constants.py index f83aaf317..a80e1e85e 100644 --- a/pynecone/constants.py +++ b/pynecone/constants.py @@ -18,7 +18,7 @@ VERSION = pkg_resources.get_distribution(PACKAGE_NAME).version MIN_NODE_VERSION = "16.6.0" # Valid bun versions. -MIN_BUN_VERSION = "0.5.8" +MIN_BUN_VERSION = "0.5.9" MAX_BUN_VERSION = "0.5.9" INVALID_BUN_VERSIONS = ["0.5.5", "0.5.6", "0.5.7"] @@ -70,8 +70,10 @@ FRONTEND_PORT = "3000" BACKEND_PORT = "8000" # The backend api url. API_URL = "http://localhost:8000" +# bun root location +BUN_ROOT_PATH = "$HOME/.bun" # The default path where bun is installed. -BUN_PATH = "$HOME/.bun/bin/bun" +BUN_PATH = f"{BUN_ROOT_PATH}/bin/bun" # Command to install bun. INSTALL_BUN = f"curl -fsSL https://bun.sh/install | bash -s -- bun-v{MAX_BUN_VERSION}" # Default host in dev mode. diff --git a/pynecone/pc.py b/pynecone/pc.py index c118a3b20..824aa56c4 100644 --- a/pynecone/pc.py +++ b/pynecone/pc.py @@ -38,25 +38,25 @@ def init( ) raise typer.Exit() - with console.status(f"[bold]Initializing {app_name}"): - # Set up the web directory. - prerequisites.install_bun() - prerequisites.initialize_web_directory() + console.rule(f"[bold]Initializing {app_name}") + # Set up the web directory. + prerequisites.validate_and_install_bun() + prerequisites.initialize_web_directory() - # Set up the app directory, only if the config doesn't exist. - if not os.path.exists(constants.CONFIG_FILE): - prerequisites.create_config(app_name) - prerequisites.initialize_app_directory(app_name, template) - build.set_pynecone_project_hash() - telemetry.send("init", get_config().telemetry_enabled) - else: - build.set_pynecone_project_hash() - telemetry.send("reinit", get_config().telemetry_enabled) + # Set up the app directory, only if the config doesn't exist. + if not os.path.exists(constants.CONFIG_FILE): + prerequisites.create_config(app_name) + prerequisites.initialize_app_directory(app_name, template) + build.set_pynecone_project_hash() + telemetry.send("init", get_config().telemetry_enabled) + else: + build.set_pynecone_project_hash() + telemetry.send("reinit", get_config().telemetry_enabled) - # Initialize the .gitignore. - prerequisites.initialize_gitignore() - # Finish initializing the app. - console.log(f"[bold green]Finished Initializing: {app_name}") + # Initialize the .gitignore. + prerequisites.initialize_gitignore() + # Finish initializing the app. + console.log(f"[bold green]Finished Initializing: {app_name}") @cli.command() diff --git a/pynecone/utils/console.py b/pynecone/utils/console.py index 545dbdf35..e2316fbd4 100644 --- a/pynecone/utils/console.py +++ b/pynecone/utils/console.py @@ -48,18 +48,21 @@ def rule(title: str) -> None: _console.rule(title) -def ask(question: str, choices: Optional[List[str]] = None) -> str: +def ask( + question: str, choices: Optional[List[str]] = None, default: Optional[str] = None +) -> str: """Takes a prompt question and optionally a list of choices and returns the user input. Args: question (str): The question to ask the user. - choices (Optional[List[str]]): A list of choices to select from + choices (Optional[List[str]]): A list of choices to select from. + default(Optional[str]): The default option selected. Returns: A string """ - return Prompt.ask(question, choices=choices) + return Prompt.ask(question, choices=choices, default=default) # type: ignore def status(msg: str) -> Status: diff --git a/pynecone/utils/exec.py b/pynecone/utils/exec.py index 68b17d0e2..7c8de880a 100644 --- a/pynecone/utils/exec.py +++ b/pynecone/utils/exec.py @@ -38,6 +38,9 @@ def run_frontend(app: App, root: Path, port: str): root: root path of the project. port: port of the app. """ + # validate bun version + prerequisites.validate_and_install_bun(initialize=False) + # Set up the frontend. setup_frontend(root) diff --git a/pynecone/utils/prerequisites.py b/pynecone/utils/prerequisites.py index 7adc4aea7..5e1865e73 100644 --- a/pynecone/utils/prerequisites.py +++ b/pynecone/utils/prerequisites.py @@ -52,7 +52,9 @@ def get_bun_version() -> Optional[version.Version]: try: # Run the bun -v command and capture the output result = subprocess.run( - ["bun", "-v"], stdout=subprocess.PIPE, stderr=subprocess.PIPE + [os.path.expandvars(get_config().bun_path), "-v"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, ) return version.parse(result.stdout.decode().strip()) except Exception: @@ -213,12 +215,16 @@ def initialize_web_directory(): json.dump(pynecone_json, f, ensure_ascii=False) -def install_bun(): - """Install bun onto the user's system. +def validate_and_install_bun(initialize=True): + """Check that bun version requirements are met. If they are not, + ask user whether to install required version. + + Args: + initialize: whether this function is called on `pc init` or `pc run`. Raises: - FileNotFoundError: If the required packages are not installed. Exit: If the bun version is not supported. + """ bun_version = get_bun_version() if bun_version is not None and ( @@ -229,11 +235,37 @@ def install_bun(): console.print( f"""[red]Bun version {bun_version} is not supported by Pynecone. Please change your to bun version to be between {constants.MIN_BUN_VERSION} and {constants.MAX_BUN_VERSION}.""" ) - console.print( - f"""[red]Upgrade by running the following command:[/red]\n\n{constants.INSTALL_BUN}""" + action = console.ask( + "Enter 'yes' to install the latest supported bun version or 'no' to exit.", + choices=["yes", "no"], + default="no", ) - raise typer.Exit() + if action == "yes": + remove_existing_bun_installation() + install_bun() + return + else: + raise typer.Exit() + + if initialize: + install_bun() + + +def remove_existing_bun_installation(): + """Remove existing bun installation.""" + package_manager = get_package_manager() + if os.path.exists(package_manager): + console.log("Removing bun...") + path_ops.rm(os.path.expandvars(constants.BUN_ROOT_PATH)) + + +def install_bun(): + """Install bun onto the user's system. + + Raises: + FileNotFoundError: if unzip or curl packages are not found. + """ # Bun is not supported on Windows. if platform.system() == "Windows": console.log("Skipping bun installation on Windows.") diff --git a/tests/test_utils.py b/tests/test_utils.py index 2a3dc5e40..f0ea7088d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,10 +2,16 @@ import typing from typing import Any, List, Union import pytest +from packaging import version from pynecone.utils import build, format, imports, prerequisites, types from pynecone.vars import Var +V055 = version.parse("0.5.5") +V059 = version.parse("0.5.9") +V056 = version.parse("0.5.6") +V0510 = version.parse("0.5.10") + @pytest.mark.parametrize( "input,output", @@ -231,6 +237,78 @@ def test_format_route(route: str, expected: bool): assert format.format_route(route) == expected +@pytest.mark.parametrize( + "bun_version,is_valid, prompt_input", + [ + (V055, False, "yes"), + (V059, True, None), + (V0510, False, "yes"), + ], +) +def test_bun_validate_and_install(mocker, bun_version, is_valid, prompt_input): + """Test that the bun version on host system is validated properly. Also test that + the required bun version is installed should the user opt for it. + + Args: + mocker: Pytest mocker object. + bun_version: The bun version. + is_valid: Whether bun version is valid for running pynecone. + prompt_input: The input from user on whether to install bun. + """ + mocker.patch( + "pynecone.utils.prerequisites.get_bun_version", return_value=bun_version + ) + mocker.patch("pynecone.utils.prerequisites.console.ask", return_value=prompt_input) + + bun_install = mocker.patch("pynecone.utils.prerequisites.install_bun") + remove_existing_bun_installation = mocker.patch( + "pynecone.utils.prerequisites.remove_existing_bun_installation" + ) + + prerequisites.validate_and_install_bun() + if not is_valid: + remove_existing_bun_installation.assert_called_once() + bun_install.assert_called_once() + + +def test_bun_validation_exception(mocker): + """Test that an exception is thrown and program exists when user selects no when asked + whether to install bun or not. + + Args: + mocker: Pytest mocker. + """ + mocker.patch("pynecone.utils.prerequisites.get_bun_version", return_value=V056) + mocker.patch("pynecone.utils.prerequisites.console.ask", return_value="no") + + with pytest.raises(RuntimeError): + prerequisites.validate_and_install_bun() + + +def test_remove_existing_bun_installation(mocker, tmp_path): + """Test that existing bun installation is removed. + + Args: + mocker: Pytest mocker. + tmp_path: test path. + """ + bun_location = tmp_path / ".bun" + bun_location.mkdir() + + mocker.patch( + "pynecone.utils.prerequisites.get_package_manager", + return_value=str(bun_location), + ) + mocker.patch( + "pynecone.utils.prerequisites.os.path.expandvars", + return_value=str(bun_location), + ) + + prerequisites.remove_existing_bun_installation() + + assert not bun_location.exists() + + def test_setup_frontend(tmp_path, mocker): """Test checking if assets content have been copied into the .web/public folder.