diff --git a/docs/installing.md b/docs/installing.md index c185255b4..94343dd91 100644 --- a/docs/installing.md +++ b/docs/installing.md @@ -40,19 +40,31 @@ mamba install -c conda-forge mystmd :::: ::::{tab-item} PyPI -🛠 Install `node` (), see [Installing NodeJS](./install-node.md): +:::{note} Install Node.js? +:class: dropdown -```shell -$ node -v -v20.4.0 -``` +The `mystmd` package on PyPI ships with the ability to install `node` (). If you would prefer to install NodeJS manually, see [Installing NodeJS](./install-node.md): +::: -🛠 Then install `mystmd`: + +🛠 Install `mystmd`: ```shell pip install mystmd ``` +🛠 Ensure `mystmd` is ready for use: + +MyST needs `node` () in order to run correctly. If `node` is not already installed, starting `myst` will prompt you to install it: + +```shell +$ myst -v +❗ Node.js (node) is required to run MyST, but could not be found`. +❔ Install Node.js in '/root/.local/share/myst/18.0.0'? (y/N): y +⚙ī¸ Attempting to install Node.js in /root/.local/share/myst/18.0.0 ... +ℹī¸ Successfully installed Node.js 18.0.0 +v1.3.4 +``` :::: ::::{tab-item} NPM diff --git a/packages/mystmd-py/pyproject.toml b/packages/mystmd-py/pyproject.toml index 6e4107e79..adfa77b93 100644 --- a/packages/mystmd-py/pyproject.toml +++ b/packages/mystmd-py/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling", "hatch-nodejs-version"] +requires = ["hatchling", "hatch-nodejs-version", "hatch-deps-selector"] build-backend = "hatchling.build" [project] @@ -34,6 +34,25 @@ myst = "mystmd_py.main:main" source = "nodejs" path = "_package.json" +[tool.hatch.build.hooks.selector] +default-variant = "pypi" +# Name of the env-var that controls which `selector.variants` entry +# is used +env-var = "MYSTMD_PACKAGE_VARIANT" + +# Ensure that we only bring in nodeenv for PyPI +[tool.hatch.build.hooks.selector.variants.pypi] +dependencies = [ + "platformdirs~=4.2.2", + "nodeenv~=1.9.1" +] + +# Conda-forge has no additional dependencies to the `project.dependencies` +# This section is not needed (the env-var can be set to "") +# But this makes it more explicit +[tool.hatch.build.hooks.selector.variants.conda-forge] +dependencies = [] + [tool.hatch.metadata.hooks.nodejs] fields = ["description", "authors", "urls", "keywords", "license"] path = "_package.json" diff --git a/packages/mystmd-py/src/mystmd_py/main.py b/packages/mystmd-py/src/mystmd_py/main.py index 0f1388c2c..0311ca023 100644 --- a/packages/mystmd-py/src/mystmd_py/main.py +++ b/packages/mystmd-py/src/mystmd_py/main.py @@ -5,30 +5,121 @@ import subprocess import sys import re +import textwrap -def main(): - NODE_LOCATION = ( - shutil.which("node") or shutil.which("node.exe") or shutil.which("node.cmd") +NODEENV_VERSION = "18.0.0" +INSTALL_NODEENV_KEY = "MYSTMD_ALLOW_NODEENV" + + +class PermissionDeniedError(Exception): ... + + +class NodeEnvCreationError(Exception): ... + + +def is_windows(): + return platform.system() == "Windows" + + +def find_installed_node(): + # shutil.which can find things with PATHEXT, but 3.12.0 breaks this by preferring NODE over NODE.EXE on Windows + return shutil.which("node.exe") if is_windows() else shutil.which("node") + + +def find_nodeenv_path(): + # The conda packaging of this package does not need to install node! + import platformdirs + + return platformdirs.user_data_path( + appname="myst", appauthor=False, version=NODEENV_VERSION ) - PATH_TO_BIN_JS = (pathlib.Path(__file__).parent / "myst.cjs").resolve() - if not NODE_LOCATION: + +def ask_to_install_node(path): + if env_value := os.environ.get(INSTALL_NODEENV_KEY, "").lower(): + return env_value in {"yes", "true", "1", "y"} + + return input(f"❔ Install Node.js in '{path}'? (y/N): ").lower() == "y" + + +def create_nodeenv(env_path): + command = [ + sys.executable, + "-m", + "nodeenv", + "-v", + f"--node={NODEENV_VERSION}", + "--prebuilt", + "--clean-src", + env_path, + ] + result = subprocess.run(command, capture_output=True, encoding="utf-8") + if result.returncode: + shutil.rmtree(env_path) + raise NodeEnvCreationError(result.stderr) + else: + return env_path + + +def find_any_node(binary_path): + node_path = find_installed_node() + if node_path is not None: + return pathlib.Path(node_path).absolute(), binary_path + + nodeenv_path = find_nodeenv_path() + if not nodeenv_path.exists(): + print("❗ Node.js (node) is required to run MyST, but could not be found`.") + if ask_to_install_node(nodeenv_path): + print(f"⚙ī¸ Attempting to install Node.js in {nodeenv_path} ...") + create_nodeenv(nodeenv_path) + print(f"ℹī¸ Successfully installed Node.js {NODEENV_VERSION}") + else: + raise PermissionDeniedError("Node.js installation was not permitted") + + # Find the executable path + new_node_path = ( + (nodeenv_path / "Scripts" / "node.exe") + if is_windows() + else (nodeenv_path / "bin" / "node") + ) + new_path = os.pathsep.join( + [*binary_path.split(os.pathsep), str(new_node_path.parent)] + ) + return new_node_path, new_path + + +def main(): + # Find NodeJS (and potential new PATH) + binary_path = os.environ.get("PATH", os.defpath) + try: + node_path, os_path = find_any_node(binary_path) + except NodeEnvCreationError as err: + message = textwrap.indent(err.args[0], " ") raise SystemExit( - "You must install node >=18 to run MyST\n\n" - "We recommend installing the latest LTS release, using your preferred package manager\n" - "or following instructions here: https://nodejs.org/en/download" - ) - node = pathlib.Path(NODE_LOCATION).absolute() + "đŸ’Ĩ The attempt to install Node.js was unsuccessful.\n" + f"🔍 Underlying error:\n{message}\n\n" + "ℹī¸ We recommend installing the latest LTS release, using your preferred package manager " + "or following instructions here: https://nodejs.org\n\n" + ) from err + except PermissionDeniedError as err: + raise SystemExit( + "đŸ’Ĩ The attempt to install Node.js failed because the user denied the request.\n" + "ℹī¸ We recommend installing the latest LTS release, using your preferred package manager " + "or following instructions here: https://nodejs.org\n\n" + ) from err + # Build new env dict + node_env = {**os.environ, "PATH": os_path} + + # Check version _version = subprocess.run( - [node, "-v"], capture_output=True, check=True, text=True + [node_path, "-v"], capture_output=True, check=True, text=True, env=node_env ).stdout major_version_match = re.match(r"v(\d+).*", _version) if major_version_match is None: raise SystemExit(f"MyST could not determine the version of Node.js: {_version}") - major_version = int(major_version_match[1]) if not (major_version in {18, 20, 22} or major_version > 22): raise SystemExit( @@ -37,16 +128,22 @@ def main(): "or following instructions here: https://nodejs.org/en/download" ) - node_args = [PATH_TO_BIN_JS, *sys.argv[1:]] - node_env = {**os.environ, "MYST_LANG": "PYTHON"} + # Find path to compiled JS + js_path = (pathlib.Path(__file__).parent / "myst.cjs").resolve() + + # Build args for Node.js process + myst_node_args = [js_path, *sys.argv[1:]] + myst_env = {**node_env, "MYST_LANG": "PYTHON"} + + # Invoke appropriate binary for platform if platform.system() == "Windows": - result = subprocess.run([node, *node_args], env=node_env) + result = subprocess.run([node_path, *myst_node_args], env=myst_env) sys.exit(result.returncode) else: os.execve( - node, - [node.name, *node_args], - node_env, + node_path, + [node_path.name, *myst_node_args], + myst_env, ) diff --git a/packages/mystmd-py/src/mystmd_py/nodeenv.py b/packages/mystmd-py/src/mystmd_py/nodeenv.py new file mode 100644 index 000000000..ca6914ee0 --- /dev/null +++ b/packages/mystmd-py/src/mystmd_py/nodeenv.py @@ -0,0 +1,76 @@ +import os +import pathlib +import shutil +import subprocess +import sys + + +NODEENV_VERSION = "18.0.0" +INSTALL_NODEENV_KEY = "MYSTMD_ALLOW_NODEENV" + + +class PermissionDeniedError(Exception): ... + + +class NodeEnvCreationError(Exception): ... + + +def find_installed_node(): + return shutil.which("node") or shutil.which("node.exe") or shutil.which("node.cmd") + + +def find_nodeenv_path(): + # The conda packaging of this package does not need to install node! + import platformdirs + return platformdirs.user_data_path( + appname="myst", appauthor=False, version=NODEENV_VERSION + ) + + +def ask_to_install_node(path): + if env_value := os.environ.get(INSTALL_NODEENV_KEY, "").lower(): + return env_value in {"yes", "true", "1", "y"} + + return input(f"❔ Install Node.js in '{path}'? (y/N): ").lower() == "y" + + +def create_nodeenv(env_path): + command = [ + sys.executable, + "-m", + "nodeenv", + "-v", + f"--node={NODEENV_VERSION}", + "--prebuilt", + "--clean-src", + env_path, + ] + result = subprocess.run(command, capture_output=True, encoding="utf-8") + if result.returncode: + shutil.rmtree(env_path) + raise NodeEnvCreationError(result.stderr) + else: + return env_path + + +def find_any_node(binary_path): + node_path = find_installed_node() + if node_path is not None: + return pathlib.Path(node_path).absolute(), binary_path + + nodeenv_path = find_nodeenv_path() + if not nodeenv_path.exists(): + print("❗ Node.js (node) is required to run MyST, but could not be found`.") + if ask_to_install_node(nodeenv_path): + print(f"⚙ī¸ Attempting to install Node.js in {nodeenv_path} ...") + create_nodeenv(nodeenv_path) + print(f"ℹī¸ Successfully installed Node.js {NODEENV_VERSION}") + else: + raise PermissionDeniedError("Node.js installation was not permitted") + + new_path = os.pathsep.join( + [*binary_path.split(os.pathsep), str(nodeenv_path / "bin")] + ) + return nodeenv_path / "bin" / "node", new_path + +