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
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.

View File

@ -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')}")

View File

@ -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

View File

@ -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()

View File

@ -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 = {

View File

@ -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()
)