Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

📦 Use nodeenv when Node.js can't be found by Python package #1487

Merged
merged 14 commits into from
Sep 3, 2024
24 changes: 18 additions & 6 deletions docs/installing.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,31 @@ mamba install -c conda-forge mystmd
::::
::::{tab-item} PyPI

🛠 Install `node` (<https://nodejs.org>), 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` (<https://nodejs.org>). 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` (<https://nodejs.org>) 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

Expand Down
21 changes: 20 additions & 1 deletion packages/mystmd-py/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[build-system]
requires = ["hatchling", "hatch-nodejs-version"]
requires = ["hatchling", "hatch-nodejs-version", "hatch-deps-selector"]
build-backend = "hatchling.build"

[project]
Expand Down Expand Up @@ -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"
Expand Down
133 changes: 115 additions & 18 deletions packages/mystmd-py/src/mystmd_py/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
)


Expand Down
76 changes: 76 additions & 0 deletions packages/mystmd-py/src/mystmd_py/nodeenv.py
Original file line number Diff line number Diff line change
@@ -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


Loading