diff --git a/reflex/constants.py b/reflex/constants.py index 98727da515..2e283e31e7 100644 --- a/reflex/constants.py +++ b/reflex/constants.py @@ -6,6 +6,7 @@ import re from enum import Enum from types import SimpleNamespace +from typing import Optional from platformdirs import PlatformDirs @@ -18,6 +19,28 @@ IS_WINDOWS = platform.system() == "Windows" +def get_fnm_name() -> Optional[str]: + """Get the appropriate fnm executable name based on the current platform. + + Returns: + The fnm executable name for the current platform. + """ + platform_os = platform.system() + + if platform_os == "Windows": + return "fnm-windows" + elif platform_os == "Darwin": + return "fnm-macos" + elif platform_os == "Linux": + machine = platform.machine() + if machine == "arm" or machine.startswith("armv7"): + return "fnm-arm32" + elif machine.startswith("aarch") or machine.startswith("armv8"): + return "fnm-arm64" + return "fnm-linux" + return None + + # App names and versions. # The name of the Reflex package. MODULE_NAME = "reflex" @@ -28,14 +51,9 @@ # The directory to store reflex dependencies. REFLEX_DIR = ( # on windows, we use C:/Users//AppData/Local/reflex. + # on macOS, we use ~/Library/Application Support/reflex. + # on linux, we use ~/.local/share/reflex. PlatformDirs(MODULE_NAME, False).user_data_dir - if IS_WINDOWS - else os.path.expandvars( - os.path.join( - "$HOME", - f".{MODULE_NAME}", - ), - ) ) # The root directory of the reflex library. ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -56,46 +74,39 @@ # Min Bun Version MIN_BUN_VERSION = "0.7.0" # The directory to store the bun. -BUN_ROOT_PATH = os.path.join(REFLEX_DIR, ".bun") +BUN_ROOT_PATH = os.path.join(REFLEX_DIR, "bun") # Default bun path. DEFAULT_BUN_PATH = os.path.join(BUN_ROOT_PATH, "bin", "bun") # URL to bun install script. BUN_INSTALL_URL = "https://bun.sh/install" -# NVM / Node config. -# The NVM version. -NVM_VERSION = "0.39.1" +# FNM / Node config. # The FNM version. FNM_VERSION = "1.35.1" # The Node version. NODE_VERSION = "18.17.0" # The minimum required node version. NODE_VERSION_MIN = "16.8.0" -# The directory to store nvm. -NVM_DIR = os.path.join(REFLEX_DIR, ".nvm") # The directory to store fnm. FNM_DIR = os.path.join(REFLEX_DIR, "fnm") +FNM_FILENAME = get_fnm_name() # The fnm executable binary. -FNM_EXE = os.path.join(FNM_DIR, "fnm.exe") -# The nvm path. -NVM_PATH = os.path.join(NVM_DIR, "nvm.sh") +FNM_EXE = os.path.join(FNM_DIR, "fnm.exe" if IS_WINDOWS else "fnm") # The node bin path. -NODE_BIN_PATH = ( - os.path.join(NVM_DIR, "versions", "node", f"v{NODE_VERSION}", "bin") - if not IS_WINDOWS - else os.path.join(FNM_DIR, "node-versions", f"v{NODE_VERSION}", "installation") +NODE_BIN_PATH = os.path.join( + FNM_DIR, + "node-versions", + f"v{NODE_VERSION}", + "installation", + "bin" if not IS_WINDOWS else "", ) # The default path where node is installed. NODE_PATH = os.path.join(NODE_BIN_PATH, "node.exe" if IS_WINDOWS else "node") # The default path where npm is installed. NPM_PATH = os.path.join(NODE_BIN_PATH, "npm") -# The URL to the nvm install script. -NVM_INSTALL_URL = ( - f"https://raw.githubusercontent.com/nvm-sh/nvm/v{NVM_VERSION}/install.sh" -) # The URL to the fnm release binary -FNM_WINDOWS_INSTALL_URL = ( - f"https://github.com/Schniz/fnm/releases/download/v{FNM_VERSION}/fnm-windows.zip" +FNM_INSTALL_URL = ( + f"https://github.com/Schniz/fnm/releases/download/v{FNM_VERSION}/{FNM_FILENAME}.zip" ) # The frontend directories in a project. # The web folder where the NextJS app is compiled to. diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index d2cb424458..97ef040a88 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -58,11 +58,13 @@ def run_frontend( """ # Start watching asset folder. start_watching_assets_folder(root) + # validate dependencies before run + prerequisites.validate_frontend_dependencies(init=False) # Run the frontend in development mode. console.rule("[bold green]App Running") os.environ["PORT"] = str(get_config().frontend_port if port is None else port) - run_process_and_launch_url([prerequisites.get_package_manager(), "run", "dev"]) + run_process_and_launch_url([prerequisites.get_package_manager(), "run", "dev"]) # type: ignore def run_frontend_prod( @@ -77,10 +79,11 @@ def run_frontend_prod( """ # Set the port. os.environ["PORT"] = str(get_config().frontend_port if port is None else port) - + # validate dependencies before run + prerequisites.validate_frontend_dependencies(init=False) # Run the frontend in production mode. console.rule("[bold green]App Running") - run_process_and_launch_url([prerequisites.get_package_manager(), "run", "prod"]) + run_process_and_launch_url([prerequisites.get_package_manager(), "run", "prod"]) # type: ignore def run_backend( @@ -155,7 +158,7 @@ def run_backend_prod( def output_system_info(): - """Show system informations if the loglevel is in DEBUG.""" + """Show system information if the loglevel is in DEBUG.""" if console.LOG_LEVEL > constants.LogLevel.DEBUG: return @@ -171,7 +174,7 @@ def output_system_info(): dependencies = [ f"[Reflex {constants.VERSION} with Python {platform.python_version()} (PATH: {sys.executable})]", - f"[Node {prerequisites.get_node_version()} (Expected: {constants.NODE_VERSION}) (PATH:{constants.NODE_PATH})]", + f"[Node {prerequisites.get_node_version()} (Expected: {constants.NODE_VERSION}) (PATH:{path_ops.get_node_path()})]", ] system = platform.system() @@ -179,7 +182,7 @@ def output_system_info(): if system != "Windows": dependencies.extend( [ - f"[NVM {constants.NVM_VERSION} (Expected: {constants.NVM_VERSION}) (PATH: {constants.NVM_PATH})]", + f"[FNM {constants.FNM_VERSION} (Expected: {constants.FNM_VERSION}) (PATH: {constants.FNM_EXE})]", f"[Bun {prerequisites.get_bun_version()} (Expected: {constants.BUN_VERSION}) (PATH: {config.bun_path})]", ], ) @@ -201,8 +204,8 @@ def output_system_info(): console.debug(f"{dep}") console.debug( - f"Using package installer at: {prerequisites.get_install_package_manager()}" + f"Using package installer at: {prerequisites.get_install_package_manager()}" # type: ignore ) - console.debug(f"Using package executer at: {prerequisites.get_package_manager()}") + console.debug(f"Using package executer at: {prerequisites.get_package_manager()}") # type: ignore if system != "Windows": console.debug(f"Unzip path: {path_ops.which('unzip')}") diff --git a/reflex/utils/path_ops.py b/reflex/utils/path_ops.py index 6cf321188c..b60771c4c1 100644 --- a/reflex/utils/path_ops.py +++ b/reflex/utils/path_ops.py @@ -4,8 +4,11 @@ import os import shutil +from pathlib import Path from typing import Optional +from reflex import constants + # Shorthand for join. join = os.linesep.join @@ -107,3 +110,37 @@ def which(program: str) -> Optional[str]: The path to the executable. """ return shutil.which(program) + + +def get_node_bin_path() -> Optional[str]: + """Get the node binary dir path. + + Returns: + The path to the node bin folder. + """ + if not os.path.exists(constants.NODE_BIN_PATH): + str_path = which("node") + return str(Path(str_path).parent) if str_path else str_path + return constants.NODE_BIN_PATH + + +def get_node_path() -> Optional[str]: + """Get the node binary path. + + Returns: + The path to the node binary file. + """ + if not os.path.exists(constants.NODE_PATH): + return which("node") + return constants.NODE_PATH + + +def get_npm_path() -> Optional[str]: + """Get npm binary path. + + Returns: + The path to the npm binary file. + """ + if not os.path.exists(constants.NODE_PATH): + return which("npm") + return constants.NPM_PATH diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py index 9e55cf136b..13ec3b6004 100644 --- a/reflex/utils/prerequisites.py +++ b/reflex/utils/prerequisites.py @@ -6,6 +6,7 @@ import json import os import re +import stat import sys import tempfile import zipfile @@ -49,10 +50,10 @@ def get_node_version() -> Optional[version.Version]: The version of node. """ try: - result = processes.new_process([constants.NODE_PATH, "-v"], run=True) + result = processes.new_process([path_ops.get_node_path(), "-v"], run=True) # The output will be in the form "vX.Y.Z", but version.parse() can handle it return version.parse(result.stdout) # type: ignore - except FileNotFoundError: + except (FileNotFoundError, TypeError): return None @@ -70,29 +71,29 @@ def get_bun_version() -> Optional[version.Version]: return None -def get_install_package_manager() -> str: +def get_install_package_manager() -> Optional[str]: """Get the package manager executable for installation. - currently on unix systems, bun is used for installation only. + Currently on unix systems, bun is used for installation only. Returns: The path to the package manager. """ # On Windows, we use npm instead of bun. if constants.IS_WINDOWS: - return constants.NPM_PATH + return path_ops.get_npm_path() # On other platforms, we use bun. return get_config().bun_path -def get_package_manager() -> str: +def get_package_manager() -> Optional[str]: """Get the package manager executable for running app. - currently on unix systems, npm is used for running the app only. + Currently on unix systems, npm is used for running the app only. Returns: The path to the package manager. """ - return constants.NPM_PATH + return path_ops.get_npm_path() def get_app() -> ModuleType: @@ -265,22 +266,19 @@ def download_and_run(url: str, *args, show_status: bool = False, **env): show(f"Installing {url}", process) -def download_and_extract_fnm_zip(url: str): +def download_and_extract_fnm_zip(): """Download and run a script. - Args: - url: The url of the fnm release zip binary. - Raises: Exit: If an error occurs while downloading or extracting the FNM zip. """ - # TODO: make this OS agnostic # Download the zip file + url = constants.FNM_INSTALL_URL console.debug(f"Downloading {url}") - fnm_zip_file = f"{constants.FNM_DIR}\\fnm_windows.zip" - # Function to download and extract the FNM zip release + fnm_zip_file = os.path.join(constants.FNM_DIR, f"{constants.FNM_FILENAME}.zip") + # Function to download and extract the FNM zip release. try: - # Download the FNM zip release + # Download the FNM zip release. # TODO: show progress to improve UX with httpx.stream("GET", url, follow_redirects=True) as response: response.raise_for_status() @@ -288,29 +286,34 @@ def download_and_extract_fnm_zip(url: str): for chunk in response.iter_bytes(): output_file.write(chunk) - # Extract the downloaded zip file + # Extract the downloaded zip file. with zipfile.ZipFile(fnm_zip_file, "r") as zip_ref: zip_ref.extractall(constants.FNM_DIR) - console.debug("FNM for Windows downloaded and extracted successfully.") + console.debug("FNM package downloaded and extracted successfully.") except Exception as e: console.error(f"An error occurred while downloading fnm package: {e}") raise typer.Exit(1) from e finally: - # Clean up the downloaded zip file + # Clean up the downloaded zip file. path_ops.rm(fnm_zip_file) def install_node(): - """Install nvm and nodejs for use by Reflex. + """Install fnm and nodejs for use by Reflex. Independent of any existing system installations. """ - if constants.IS_WINDOWS: - path_ops.mkdir(constants.FNM_DIR) - if not os.path.exists(constants.FNM_EXE): - download_and_extract_fnm_zip(constants.FNM_WINDOWS_INSTALL_URL) + if not constants.FNM_FILENAME: + # fnm only support Linux, macOS and Windows distros. + console.debug("") + return - # Install node. + path_ops.mkdir(constants.FNM_DIR) + if not os.path.exists(constants.FNM_EXE): + download_and_extract_fnm_zip() + + if constants.IS_WINDOWS: + # Install node process = processes.new_process( [ "powershell", @@ -318,22 +321,19 @@ def install_node(): f'& "{constants.FNM_EXE}" install {constants.NODE_VERSION} --fnm-dir "{constants.FNM_DIR}"', ], ) - else: # All other platforms (Linux, MacOS) + else: # All other platforms (Linux, MacOS). # TODO we can skip installation if check_node_version() checks out - # Create the nvm directory and install. - path_ops.mkdir(constants.NVM_DIR) - env = {**os.environ, "NVM_DIR": constants.NVM_DIR} - download_and_run(constants.NVM_INSTALL_URL, show_status=True, **env) - + # Add execute permissions to fnm executable. + os.chmod(constants.FNM_EXE, stat.S_IXUSR) # Install node. - # We use bash -c as we need to source nvm.sh to use nvm. process = processes.new_process( [ - "bash", - "-c", - f". {constants.NVM_DIR}/nvm.sh && nvm install {constants.NODE_VERSION}", - ], - env=env, + constants.FNM_EXE, + "install", + constants.NODE_VERSION, + "--fnm-dir", + constants.FNM_DIR, + ] ) processes.show_status("Installing node", process) @@ -461,11 +461,38 @@ def validate_bun(): raise typer.Exit(1) -def validate_frontend_dependencies(): - """Validate frontend dependencies to ensure they meet requirements.""" +def validate_frontend_dependencies(init=True): + """Validate frontend dependencies to ensure they meet requirements. + + Args: + init: whether running `reflex init` + + Raises: + Exit: If the package manager is invalid. + """ + if not init: + # we only need to validate the package manager when running app. + # `reflex init` will install the deps anyway(if applied). + package_manager = get_package_manager() + if not package_manager: + console.error( + "Could not find NPM package manager. Make sure you have node installed." + ) + raise typer.Exit(1) + + if not check_node_version(): + node_version = get_node_version() + console.error( + f"Reflex requires node version {constants.NODE_VERSION_MIN} or higher to run, but the detected version is {node_version}", + ) + raise typer.Exit(1) + if constants.IS_WINDOWS: return - return validate_bun() + + if init: + # we only need bun for package install on `reflex init`. + validate_bun() def initialize_frontend_dependencies(): @@ -476,7 +503,6 @@ def initialize_frontend_dependencies(): validate_frontend_dependencies() # Install the frontend dependencies. processes.run_concurrently(install_node, install_bun) - # Set up the web directory. initialize_web_directory() diff --git a/reflex/utils/processes.py b/reflex/utils/processes.py index efcd449918..3b6ecccef5 100644 --- a/reflex/utils/processes.py +++ b/reflex/utils/processes.py @@ -13,8 +13,7 @@ import psutil import typer -from reflex import constants -from reflex.utils import console, prerequisites +from reflex.utils import console, path_ops, prerequisites def kill(pid): @@ -126,10 +125,16 @@ def new_process(args, run: bool = False, show_logs: bool = False, **kwargs): Returns: Execute a child program in a new process. """ + node_bin_path = path_ops.get_node_bin_path() + if not node_bin_path: + console.warn( + "The path to the Node binary could not be found. Please ensure that Node is properly " + "installed and added to your system's PATH environment variable." + ) # Add the node bin path to the PATH environment variable. env = { **os.environ, - "PATH": os.pathsep.join([constants.NODE_BIN_PATH, os.environ["PATH"]]), + "PATH": os.pathsep.join([node_bin_path if node_bin_path else "", os.environ["PATH"]]), # type: ignore **kwargs.pop("env", {}), } kwargs = { diff --git a/tests/test_utils.py b/tests/test_utils.py index e1e96d67ea..33b5332d32 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -550,25 +550,31 @@ class Resp(Base): def test_node_install_unix(tmp_path, mocker): - nvm_root_path = tmp_path / ".reflex" / ".nvm" + fnm_root_path = tmp_path / "reflex" / "fnm" + fnm_exe = fnm_root_path / "fnm" - mocker.patch("reflex.utils.prerequisites.constants.NVM_DIR", nvm_root_path) + mocker.patch("reflex.utils.prerequisites.constants.FNM_DIR", fnm_root_path) + mocker.patch("reflex.utils.prerequisites.constants.FNM_EXE", fnm_exe) mocker.patch("reflex.utils.prerequisites.constants.IS_WINDOWS", False) class Resp(Base): status_code = 200 text = "test" - mocker.patch("httpx.get", return_value=Resp()) - download = mocker.patch("reflex.utils.prerequisites.download_and_run") - mocker.patch("reflex.utils.processes.new_process") + mocker.patch("httpx.stream", return_value=Resp()) + download = mocker.patch("reflex.utils.prerequisites.download_and_extract_fnm_zip") + process = mocker.patch("reflex.utils.processes.new_process") + chmod = mocker.patch("reflex.utils.prerequisites.os.chmod") mocker.patch("reflex.utils.processes.stream_logs") prerequisites.install_node() - assert nvm_root_path.exists() - download.assert_called() - download.call_count = 2 + assert fnm_root_path.exists() + download.assert_called_once() + process.assert_called_with( + [fnm_exe, "install", constants.NODE_VERSION, "--fnm-dir", fnm_root_path] + ) + chmod.assert_called_once() def test_bun_install_without_unzip(mocker): @@ -597,6 +603,8 @@ def test_create_reflex_dir(mocker, is_windows): mocker.patch("reflex.utils.prerequisites.constants.IS_WINDOWS", is_windows) mocker.patch("reflex.utils.prerequisites.processes.run_concurrently", mocker.Mock()) mocker.patch("reflex.utils.prerequisites.initialize_web_directory", mocker.Mock()) + mocker.patch("reflex.utils.processes.run_concurrently") + mocker.patch("reflex.utils.prerequisites.validate_bun") create_cmd = mocker.patch( "reflex.utils.prerequisites.path_ops.mkdir", mocker.Mock() )