Przeglądaj źródła

Fnm and node for POSIX (#1606)

Elijah Ahianyo 1 rok temu
rodzic
commit
dbaa6a1e56

+ 37 - 26
reflex/constants.py

@@ -6,6 +6,7 @@ import platform
 import re
 from enum import Enum
 from types import SimpleNamespace
+from typing import Optional
 
 from platformdirs import PlatformDirs
 
@@ -18,6 +19,28 @@ except ImportError:
 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 @@ VERSION = metadata.version(MODULE_NAME)
 # The directory to store reflex dependencies.
 REFLEX_DIR = (
     # on windows, we use C:/Users/<username>/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 @@ BUN_VERSION = "0.7.0"
 # 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.

+ 11 - 8
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')}")

+ 37 - 0
reflex/utils/path_ops.py

@@ -4,8 +4,11 @@ from __future__ import annotations
 
 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

+ 67 - 41
reflex/utils/prerequisites.py

@@ -6,6 +6,7 @@ import glob
 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()
 

+ 8 - 3
reflex/utils/processes.py

@@ -13,8 +13,7 @@ from typing import Callable, Generator, List, Optional, Tuple, Union
 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 = {

+ 16 - 8
tests/test_utils.py

@@ -550,25 +550,31 @@ def test_node_install_windows(tmp_path, mocker):
 
 
 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()
     )