diff --git a/jupyter_builder/federated_extensions.py b/jupyter_builder/federated_extensions.py index eff1f95..5e100ab 100644 --- a/jupyter_builder/federated_extensions.py +++ b/jupyter_builder/federated_extensions.py @@ -32,6 +32,8 @@ from .commands import _test_overlap from .core_path import get_core_meta +from .jlpm import _which_node_js +from .jupyterlab_semver import clean, satisfies DEPRECATED_ARGUMENT = object() @@ -238,13 +240,15 @@ def build_labextension( # noqa: PLR0913 logger.info("Building extension in %s", path) builder, marker_pkg = _ensure_builder(ext_path, core_package_file) + _check_node_version(builder, ext_path, logger=logger) if marker_pkg == "@jupyterlab/builder": core_flag = ["--core-path", _resolve_core_path_for_jupyterlab_builder(core_package_file)] else: core_flag = ["--core-package-file", core_package_file] - arguments = ["node", builder, *core_flag, ext_path] + node = _which_node_js() + arguments = [node, builder, *core_flag, ext_path] if static_url is not None: arguments.extend(["--static-url", static_url]) if development: @@ -296,13 +300,15 @@ def watch_labextension( # noqa: PLR0913 Path(full_dest).symlink_to(output_dir) builder, marker_pkg = _ensure_builder(ext_path, core_package_file) + _check_node_version(builder, ext_path, logger=logger) if marker_pkg == "@jupyterlab/builder": core_flag = ["--core-path", _resolve_core_path_for_jupyterlab_builder(core_package_file)] else: core_flag = ["--core-package-file", core_package_file] - arguments = ["node", builder, *core_flag, "--watch", ext_path] + node = _which_node_js() + arguments = [node, builder, *core_flag, "--watch", ext_path] if development: arguments.append("--development") if source_map: @@ -320,6 +326,56 @@ def watch_labextension( # noqa: PLR0913 # of preference. _BUILDER_MARKER_CANDIDATES = ("@jupyter/builder", "@jupyterlab/builder") +# Minimum Node.js range required by `@rspack/core` when its own `engines.node` +# field cannot be read. `@rspack/core` is a pure ES module that older Node.js +# versions cannot `require()`. +_FALLBACK_NODE_RANGE = "^20.19.0 || >=22.12.0" + + +def _read_rspack_node_range(builder: str, ext_path: str) -> str: + """Return the ``engines.node`` range declared by ``@rspack/core``.""" + for root in (Path(builder).parent, Path(ext_path)): + target = root + while True: + pkg = target / "node_modules" / "@rspack" / "core" / "package.json" + if pkg.exists(): + try: + with pkg.open() as fid: + node_range = json.load(fid).get("engines", {}).get("node") + except (OSError, ValueError): + node_range = None + if node_range: + return str(node_range) + break + if target.parent == target: + break + target = target.parent + return _FALLBACK_NODE_RANGE + + +def _check_node_version( + builder: str, + ext_path: str, + logger: logging.Logger | None = None, +) -> None: + """Fail early with a clear message when Node.js is too old to load the builder.""" + node = _which_node_js() + node_range = _read_rspack_node_range(builder, ext_path) + try: + raw = subprocess.check_output([node, "--version"]).decode("utf8").strip() # noqa: S603 + except (OSError, subprocess.CalledProcessError): + return + current = clean(raw, loose=True) # type: ignore[no-untyped-call] + if current is None or satisfies(current, node_range, loose=True): # type: ignore[no-untyped-call] + return + msg = ( + f"Building this extension requires Node.js {node_range} (found {raw}). " + "Please upgrade Node.js." + ) + if logger: + logger.error(msg) + raise RuntimeError(msg) + def _resolve_core_path_for_jupyterlab_builder(core_package_file: str) -> str: """Return the core path directory for @jupyterlab/builder. diff --git a/tests/test_core_path.py b/tests/test_core_path.py index ba6a5b4..927744a 100644 --- a/tests/test_core_path.py +++ b/tests/test_core_path.py @@ -9,8 +9,12 @@ import pytest -from jupyter_builder import core_path -from jupyter_builder.federated_extensions import _ensure_builder +from jupyter_builder import core_path, federated_extensions +from jupyter_builder.federated_extensions import ( + _check_node_version, + _ensure_builder, + _read_rspack_node_range, +) def _make_core_package_tarball(content: bytes) -> bytes: @@ -338,3 +342,56 @@ def test_ensure_builder_with_jupyterlab_builder(tmp_path): assert builder_path == str(builder_dir / "lib" / "build-labextension.js") assert marker_pkg == "@jupyterlab/builder" + + +def _write_rspack(ext_path: Path, node_range: str) -> None: + rspack_dir = ext_path / "node_modules" / "@rspack" / "core" + rspack_dir.mkdir(parents=True) + (rspack_dir / "package.json").write_text(json.dumps({"engines": {"node": node_range}})) + + +def test_read_rspack_node_range_reads_engines(tmp_path): + ext_path = tmp_path / "ext" + ext_path.mkdir() + _write_rspack(ext_path, "^20.19.0 || >=22.12.0") + + builder = str(ext_path / "node_modules" / "@jupyter" / "builder" / "lib" / "x.js") + assert _read_rspack_node_range(builder, str(ext_path)) == "^20.19.0 || >=22.12.0" + + +def test_read_rspack_node_range_falls_back_when_missing(tmp_path): + ext_path = tmp_path / "ext" + ext_path.mkdir() + + assert _read_rspack_node_range(str(ext_path), str(ext_path)) == "^20.19.0 || >=22.12.0" + + +def test_check_node_version_raises_on_old_node(tmp_path, monkeypatch): + ext_path = tmp_path / "ext" + ext_path.mkdir() + _write_rspack(ext_path, "^20.19.0 || >=22.12.0") + + monkeypatch.setattr(federated_extensions, "_which_node_js", lambda: "node") + monkeypatch.setattr( + federated_extensions.subprocess, + "check_output", + lambda *_args, **_kwargs: b"v18.20.8\n", + ) + + with pytest.raises(RuntimeError, match=r"requires Node\.js .* \(found v18\.20\.8\)"): + _check_node_version(str(ext_path), str(ext_path)) + + +def test_check_node_version_passes_on_supported_node(tmp_path, monkeypatch): + ext_path = tmp_path / "ext" + ext_path.mkdir() + _write_rspack(ext_path, "^20.19.0 || >=22.12.0") + + monkeypatch.setattr(federated_extensions, "_which_node_js", lambda: "node") + monkeypatch.setattr( + federated_extensions.subprocess, + "check_output", + lambda *_args, **_kwargs: b"v22.12.0\n", + ) + + _check_node_version(str(ext_path), str(ext_path))