Fnm and node for POSIX (#1606)

This commit is contained in:
Elijah Ahianyo 2023-08-25 20:04:10 +00:00 committed by GitHub
parent 76b8af3b42
commit dbaa6a1e56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 176 additions and 86 deletions

View File

@ -6,6 +6,7 @@ import platform
import re import re
from enum import Enum from enum import Enum
from types import SimpleNamespace from types import SimpleNamespace
from typing import Optional
from platformdirs import PlatformDirs from platformdirs import PlatformDirs
@ -18,6 +19,28 @@ except ImportError:
IS_WINDOWS = platform.system() == "Windows" 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. # App names and versions.
# The name of the Reflex package. # The name of the Reflex package.
MODULE_NAME = "reflex" MODULE_NAME = "reflex"
@ -28,14 +51,9 @@ VERSION = metadata.version(MODULE_NAME)
# The directory to store reflex dependencies. # The directory to store reflex dependencies.
REFLEX_DIR = ( REFLEX_DIR = (
# on windows, we use C:/Users/<username>/AppData/Local/reflex. # 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 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. # The root directory of the reflex library.
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 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
MIN_BUN_VERSION = "0.7.0" MIN_BUN_VERSION = "0.7.0"
# The directory to store the bun. # 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.
DEFAULT_BUN_PATH = os.path.join(BUN_ROOT_PATH, "bin", "bun") DEFAULT_BUN_PATH = os.path.join(BUN_ROOT_PATH, "bin", "bun")
# URL to bun install script. # URL to bun install script.
BUN_INSTALL_URL = "https://bun.sh/install" BUN_INSTALL_URL = "https://bun.sh/install"
# NVM / Node config. # FNM / Node config.
# The NVM version.
NVM_VERSION = "0.39.1"
# The FNM version. # The FNM version.
FNM_VERSION = "1.35.1" FNM_VERSION = "1.35.1"
# The Node version. # The Node version.
NODE_VERSION = "18.17.0" NODE_VERSION = "18.17.0"
# The minimum required node version. # The minimum required node version.
NODE_VERSION_MIN = "16.8.0" NODE_VERSION_MIN = "16.8.0"
# The directory to store nvm.
NVM_DIR = os.path.join(REFLEX_DIR, ".nvm")
# The directory to store fnm. # The directory to store fnm.
FNM_DIR = os.path.join(REFLEX_DIR, "fnm") FNM_DIR = os.path.join(REFLEX_DIR, "fnm")
FNM_FILENAME = get_fnm_name()
# The fnm executable binary. # The fnm executable binary.
FNM_EXE = os.path.join(FNM_DIR, "fnm.exe") FNM_EXE = os.path.join(FNM_DIR, "fnm.exe" if IS_WINDOWS else "fnm")
# The nvm path.
NVM_PATH = os.path.join(NVM_DIR, "nvm.sh")
# The node bin path. # The node bin path.
NODE_BIN_PATH = ( NODE_BIN_PATH = os.path.join(
os.path.join(NVM_DIR, "versions", "node", f"v{NODE_VERSION}", "bin") FNM_DIR,
if not IS_WINDOWS "node-versions",
else os.path.join(FNM_DIR, "node-versions", f"v{NODE_VERSION}", "installation") f"v{NODE_VERSION}",
"installation",
"bin" if not IS_WINDOWS else "",
) )
# The default path where node is installed. # The default path where node is installed.
NODE_PATH = os.path.join(NODE_BIN_PATH, "node.exe" if IS_WINDOWS else "node") NODE_PATH = os.path.join(NODE_BIN_PATH, "node.exe" if IS_WINDOWS else "node")
# The default path where npm is installed. # The default path where npm is installed.
NPM_PATH = os.path.join(NODE_BIN_PATH, "npm") 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 # The URL to the fnm release binary
FNM_WINDOWS_INSTALL_URL = ( FNM_INSTALL_URL = (
f"https://github.com/Schniz/fnm/releases/download/v{FNM_VERSION}/fnm-windows.zip" f"https://github.com/Schniz/fnm/releases/download/v{FNM_VERSION}/{FNM_FILENAME}.zip"
) )
# The frontend directories in a project. # The frontend directories in a project.
# The web folder where the NextJS app is compiled to. # The web folder where the NextJS app is compiled to.

View File

@ -58,11 +58,13 @@ def run_frontend(
""" """
# Start watching asset folder. # Start watching asset folder.
start_watching_assets_folder(root) start_watching_assets_folder(root)
# validate dependencies before run
prerequisites.validate_frontend_dependencies(init=False)
# Run the frontend in development mode. # Run the frontend in development mode.
console.rule("[bold green]App Running") console.rule("[bold green]App Running")
os.environ["PORT"] = str(get_config().frontend_port if port is None else port) 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( def run_frontend_prod(
@ -77,10 +79,11 @@ def run_frontend_prod(
""" """
# Set the port. # Set the port.
os.environ["PORT"] = str(get_config().frontend_port if port is None else 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. # Run the frontend in production mode.
console.rule("[bold green]App Running") 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( def run_backend(
@ -155,7 +158,7 @@ def run_backend_prod(
def output_system_info(): 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: if console.LOG_LEVEL > constants.LogLevel.DEBUG:
return return
@ -171,7 +174,7 @@ def output_system_info():
dependencies = [ dependencies = [
f"[Reflex {constants.VERSION} with Python {platform.python_version()} (PATH: {sys.executable})]", 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() system = platform.system()
@ -179,7 +182,7 @@ def output_system_info():
if system != "Windows": if system != "Windows":
dependencies.extend( 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})]", 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"{dep}")
console.debug( 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": if system != "Windows":
console.debug(f"Unzip path: {path_ops.which('unzip')}") console.debug(f"Unzip path: {path_ops.which('unzip')}")

View File

@ -4,8 +4,11 @@ from __future__ import annotations
import os import os
import shutil import shutil
from pathlib import Path
from typing import Optional from typing import Optional
from reflex import constants
# Shorthand for join. # Shorthand for join.
join = os.linesep.join join = os.linesep.join
@ -107,3 +110,37 @@ def which(program: str) -> Optional[str]:
The path to the executable. The path to the executable.
""" """
return shutil.which(program) 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

View File

@ -6,6 +6,7 @@ import glob
import json import json
import os import os
import re import re
import stat
import sys import sys
import tempfile import tempfile
import zipfile import zipfile
@ -49,10 +50,10 @@ def get_node_version() -> Optional[version.Version]:
The version of node. The version of node.
""" """
try: 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 # The output will be in the form "vX.Y.Z", but version.parse() can handle it
return version.parse(result.stdout) # type: ignore return version.parse(result.stdout) # type: ignore
except FileNotFoundError: except (FileNotFoundError, TypeError):
return None return None
@ -70,29 +71,29 @@ def get_bun_version() -> Optional[version.Version]:
return None return None
def get_install_package_manager() -> str: def get_install_package_manager() -> Optional[str]:
"""Get the package manager executable for installation. """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: Returns:
The path to the package manager. The path to the package manager.
""" """
# On Windows, we use npm instead of bun. # On Windows, we use npm instead of bun.
if constants.IS_WINDOWS: if constants.IS_WINDOWS:
return constants.NPM_PATH return path_ops.get_npm_path()
# On other platforms, we use bun. # On other platforms, we use bun.
return get_config().bun_path return get_config().bun_path
def get_package_manager() -> str: def get_package_manager() -> Optional[str]:
"""Get the package manager executable for running app. """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: Returns:
The path to the package manager. The path to the package manager.
""" """
return constants.NPM_PATH return path_ops.get_npm_path()
def get_app() -> ModuleType: 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) show(f"Installing {url}", process)
def download_and_extract_fnm_zip(url: str): def download_and_extract_fnm_zip():
"""Download and run a script. """Download and run a script.
Args:
url: The url of the fnm release zip binary.
Raises: Raises:
Exit: If an error occurs while downloading or extracting the FNM zip. Exit: If an error occurs while downloading or extracting the FNM zip.
""" """
# TODO: make this OS agnostic
# Download the zip file # Download the zip file
url = constants.FNM_INSTALL_URL
console.debug(f"Downloading {url}") console.debug(f"Downloading {url}")
fnm_zip_file = f"{constants.FNM_DIR}\\fnm_windows.zip" fnm_zip_file = os.path.join(constants.FNM_DIR, f"{constants.FNM_FILENAME}.zip")
# Function to download and extract the FNM zip release # Function to download and extract the FNM zip release.
try: try:
# Download the FNM zip release # Download the FNM zip release.
# TODO: show progress to improve UX # TODO: show progress to improve UX
with httpx.stream("GET", url, follow_redirects=True) as response: with httpx.stream("GET", url, follow_redirects=True) as response:
response.raise_for_status() response.raise_for_status()
@ -288,29 +286,34 @@ def download_and_extract_fnm_zip(url: str):
for chunk in response.iter_bytes(): for chunk in response.iter_bytes():
output_file.write(chunk) output_file.write(chunk)
# Extract the downloaded zip file # Extract the downloaded zip file.
with zipfile.ZipFile(fnm_zip_file, "r") as zip_ref: with zipfile.ZipFile(fnm_zip_file, "r") as zip_ref:
zip_ref.extractall(constants.FNM_DIR) 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: except Exception as e:
console.error(f"An error occurred while downloading fnm package: {e}") console.error(f"An error occurred while downloading fnm package: {e}")
raise typer.Exit(1) from e raise typer.Exit(1) from e
finally: finally:
# Clean up the downloaded zip file # Clean up the downloaded zip file.
path_ops.rm(fnm_zip_file) path_ops.rm(fnm_zip_file)
def install_node(): 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. Independent of any existing system installations.
""" """
if constants.IS_WINDOWS: if not constants.FNM_FILENAME:
path_ops.mkdir(constants.FNM_DIR) # fnm only support Linux, macOS and Windows distros.
if not os.path.exists(constants.FNM_EXE): console.debug("")
download_and_extract_fnm_zip(constants.FNM_WINDOWS_INSTALL_URL) 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( process = processes.new_process(
[ [
"powershell", "powershell",
@ -318,22 +321,19 @@ def install_node():
f'& "{constants.FNM_EXE}" install {constants.NODE_VERSION} --fnm-dir "{constants.FNM_DIR}"', 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 # TODO we can skip installation if check_node_version() checks out
# Create the nvm directory and install. # Add execute permissions to fnm executable.
path_ops.mkdir(constants.NVM_DIR) os.chmod(constants.FNM_EXE, stat.S_IXUSR)
env = {**os.environ, "NVM_DIR": constants.NVM_DIR}
download_and_run(constants.NVM_INSTALL_URL, show_status=True, **env)
# Install node. # Install node.
# We use bash -c as we need to source nvm.sh to use nvm.
process = processes.new_process( process = processes.new_process(
[ [
"bash", constants.FNM_EXE,
"-c", "install",
f". {constants.NVM_DIR}/nvm.sh && nvm install {constants.NODE_VERSION}", constants.NODE_VERSION,
], "--fnm-dir",
env=env, constants.FNM_DIR,
]
) )
processes.show_status("Installing node", process) processes.show_status("Installing node", process)
@ -461,11 +461,38 @@ def validate_bun():
raise typer.Exit(1) raise typer.Exit(1)
def validate_frontend_dependencies(): def validate_frontend_dependencies(init=True):
"""Validate frontend dependencies to ensure they meet requirements.""" """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: if constants.IS_WINDOWS:
return return
return validate_bun()
if init:
# we only need bun for package install on `reflex init`.
validate_bun()
def initialize_frontend_dependencies(): def initialize_frontend_dependencies():
@ -476,7 +503,6 @@ def initialize_frontend_dependencies():
validate_frontend_dependencies() validate_frontend_dependencies()
# Install the frontend dependencies. # Install the frontend dependencies.
processes.run_concurrently(install_node, install_bun) processes.run_concurrently(install_node, install_bun)
# Set up the web directory. # Set up the web directory.
initialize_web_directory() initialize_web_directory()

View File

@ -13,8 +13,7 @@ from typing import Callable, Generator, List, Optional, Tuple, Union
import psutil import psutil
import typer import typer
from reflex import constants from reflex.utils import console, path_ops, prerequisites
from reflex.utils import console, prerequisites
def kill(pid): def kill(pid):
@ -126,10 +125,16 @@ def new_process(args, run: bool = False, show_logs: bool = False, **kwargs):
Returns: Returns:
Execute a child program in a new process. 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. # Add the node bin path to the PATH environment variable.
env = { env = {
**os.environ, **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.pop("env", {}),
} }
kwargs = { kwargs = {

View File

@ -550,25 +550,31 @@ def test_node_install_windows(tmp_path, mocker):
def test_node_install_unix(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) mocker.patch("reflex.utils.prerequisites.constants.IS_WINDOWS", False)
class Resp(Base): class Resp(Base):
status_code = 200 status_code = 200
text = "test" text = "test"
mocker.patch("httpx.get", return_value=Resp()) mocker.patch("httpx.stream", return_value=Resp())
download = mocker.patch("reflex.utils.prerequisites.download_and_run") download = mocker.patch("reflex.utils.prerequisites.download_and_extract_fnm_zip")
mocker.patch("reflex.utils.processes.new_process") process = mocker.patch("reflex.utils.processes.new_process")
chmod = mocker.patch("reflex.utils.prerequisites.os.chmod")
mocker.patch("reflex.utils.processes.stream_logs") mocker.patch("reflex.utils.processes.stream_logs")
prerequisites.install_node() prerequisites.install_node()
assert nvm_root_path.exists() assert fnm_root_path.exists()
download.assert_called() download.assert_called_once()
download.call_count = 2 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): 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.constants.IS_WINDOWS", is_windows)
mocker.patch("reflex.utils.prerequisites.processes.run_concurrently", mocker.Mock()) mocker.patch("reflex.utils.prerequisites.processes.run_concurrently", mocker.Mock())
mocker.patch("reflex.utils.prerequisites.initialize_web_directory", 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( create_cmd = mocker.patch(
"reflex.utils.prerequisites.path_ops.mkdir", mocker.Mock() "reflex.utils.prerequisites.path_ops.mkdir", mocker.Mock()
) )