Fnm and node for POSIX (#1606)
This commit is contained in:
parent
76b8af3b42
commit
dbaa6a1e56
@ -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.
|
||||
|
@ -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')}")
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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 = {
|
||||
|
@ -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()
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user