diff options
| author | kj_sh604 | 2026-05-19 20:59:16 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-05-19 20:59:16 -0400 |
| commit | 7e033c9d81a3513c40db9786cb61715708b8d61d (patch) | |
| tree | e38ab3c1ccbea96b304f1d0a8ece7e5368e76676 | |
| parent | 0f1c1a85ea6cfc75118e3469576977d6e05365df (diff) | |
refactor: build-jupyterlab.sh
| -rwxr-xr-x | build-jupyterlab.sh | 262 |
1 files changed, 262 insertions, 0 deletions
diff --git a/build-jupyterlab.sh b/build-jupyterlab.sh new file mode 100755 index 0000000..d01e89b --- /dev/null +++ b/build-jupyterlab.sh @@ -0,0 +1,262 @@ +#!/bin/sh + +# build-jupyterlab.sh +# installs jupyterlab and the full jupyter ecosystem via miniforge, +# then packages everything as a portable AppImage. + +# usage: +# sh build-jupyterlab.sh +# JUPYTERLAB_VER=4.3.0 sh build-jupyterlab.sh # pin jupyterlab version +# +# running the output AppImage: +# regular: ./jupyterlab-VERSION-ARCH.AppImage lab +# notebook classic: ./jupyterlab-VERSION-ARCH.AppImage notebook +# any jupyter cmd: ./jupyterlab-VERSION-ARCH.AppImage nbconvert --to html notebook.ipynb +# docker/no-fuse: APPIMAGE_EXTRACT_AND_RUN=1 ./jupyterlab-VERSION-ARCH.AppImage lab + +set -e + +PKGNAME="jupyterlab" +# - set JUPYTERLAB_VER to pin a specific release, or leave empty for latest +JUPYTERLAB_VER="${JUPYTERLAB_VER:-}" + +# - check required tools +for _cmd in curl bash; do + if ! command -v "$_cmd" > /dev/null 2>&1; then + printf 'error: required tool not found: %s\n' "$_cmd" >&2 + exit 1 + fi +done + +# - detect host architecture +UNAME_ARCH="$(uname -m)" +case "$UNAME_ARCH" in + x86_64) + MINIFORGE_ARCH="x86_64" + ;; + aarch64) + MINIFORGE_ARCH="aarch64" + ;; + *) + printf 'error: unsupported architecture: %s\n' "$UNAME_ARCH" >&2 + exit 1 + ;; +esac + +# - miniforge latest release URL - always resolves to the most recent stable release +MINIFORGE_INSTALLER="Miniforge3-Linux-${MINIFORGE_ARCH}.sh" +MINIFORGE_URL="https://github.com/conda-forge/miniforge/releases/latest/download/${MINIFORGE_INSTALLER}" +APPIMAGETOOL_URL="https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${UNAME_ARCH}.AppImage" + +OUTPUT_DIR="$(pwd)" +WORKDIR="$(mktemp -d)" +APPDIR="${WORKDIR}/AppDir" +CONDA_DIR="${APPDIR}/usr/lib/miniforge" + +# - always clean up temp workdir on exit +cleanup() { + rm -rf "$WORKDIR" +} +trap cleanup EXIT INT TERM + +printf '==> setting up build directory...\n' +mkdir -p \ + "${APPDIR}/usr/bin" \ + "${APPDIR}/usr/share/icons/hicolor/512x512/apps" + +cd "$WORKDIR" + +# - download miniforge installer (self-contained python + conda distribution) +printf '==> downloading miniforge (%s)...\n' "$MINIFORGE_ARCH" +curl -fL --progress-bar -o "${MINIFORGE_INSTALLER}" "${MINIFORGE_URL}" + +# - install miniforge silently into AppDir - no PATH modifications on the host +printf '==> installing miniforge into AppDir...\n' +bash "${MINIFORGE_INSTALLER}" -b -p "${CONDA_DIR}" + +# - install jupyterlab and the full jupyter ecosystem via pip +printf '==> installing jupyterlab and ecosystem packages...\n' +if [ -n "$JUPYTERLAB_VER" ]; then + _jl_spec="jupyterlab==${JUPYTERLAB_VER}" +else + _jl_spec="jupyterlab" +fi + +"${CONDA_DIR}/bin/pip" install --no-cache-dir \ + "${_jl_spec}" \ + notebook \ + nbclassic \ + jupyter-console \ + jupyter-server \ + "nbconvert[all]" \ + nbformat \ + ipykernel \ + ipython \ + ipywidgets \ + widgetsnbextension \ + jupyterlab_widgets \ + jupyterlab-git \ + jupyter-lsp \ + "python-lsp-server[all]" \ + jupyterlab-lsp \ + jupytext \ + nbdime \ + voila \ + jupyterlab-rise \ + jupyterlab_code_formatter \ + black \ + isort \ + ruff \ + jupyter-resource-usage + +# - query the installed jupyterlab version for the output filename +PKGVER="$("${CONDA_DIR}/bin/python3" -c \ + 'import importlib.metadata; print(importlib.metadata.version("jupyterlab"))')" +printf '==> jupyterlab version: %s\n' "$PKGVER" + +# - register the python kernel spec within the bundled environment +printf '==> registering python3 kernel...\n' +"${CONDA_DIR}/bin/python3" -m ipykernel install \ + --sys-prefix \ + --name "python3" \ + --display-name "Python 3 (AppImage)" + +# - patch kernel.json: replace the absolute python path with PATH-relative 'python3' +# - this makes the kernel portable across different AppImage mount paths at runtime +_kernel_json="${CONDA_DIR}/share/jupyter/kernels/python3/kernel.json" +"${CONDA_DIR}/bin/python3" -c " +import json, sys +path = sys.argv[1] +with open(path, 'r') as f: + spec = json.load(f) +spec['argv'][0] = 'python3' +with open(path, 'w') as f: + json.dump(spec, f, indent=1) +" "$_kernel_json" + +# - enable nbdime git integration (best-effort - may fail if git is unavailable) +"${CONDA_DIR}/bin/nbdime" config-git --enable --global 2>/dev/null || true + +# - patch python shebangs in all bundled scripts to use env-based lookup +# - jupyter dispatches subcommands (e.g. jupyter-notebook) by spawning them as +# new processes, which means their shebangs are executed by the kernel; +# the build-time absolute paths won't exist at runtime, so we replace them +# with '#!/usr/bin/env python3' - apprun exports PATH with the bundled python +# first, so env resolves to the correct interpreter in all child processes +printf '==> patching script shebangs for portability...\n' +find "${CONDA_DIR}/bin" -maxdepth 1 -type f | while IFS= read -r _f; do + case "$(head -1 "$_f" 2>/dev/null)" in + '#!'*python*) + sed -i "1s|^#!.*|#!/usr/bin/env python3|" "$_f" + ;; + esac +done + +# - clean conda/pip caches to reduce AppImage size +printf '==> cleaning caches to reduce AppImage size...\n' +"${CONDA_DIR}/bin/conda" clean --tarballs --index-cache --packages --yes 2>/dev/null || true +find "${CONDA_DIR}" -name '__pycache__' -type d -exec rm -rf '{}' + 2>/dev/null || true +find "${CONDA_DIR}" -name '*.pyc' -delete 2>/dev/null || true + +# - AppRun is the entrypoint; resolves all paths relative to the AppImage mount +# - PYTHONHOME tells Python where to find its stdlib in the relocated environment +# - LD_LIBRARY_PATH ensures bundled shared libs are found before system ones +# - calling python3 directly with the jupyter script bypasses hardcoded shebangs +cat > "${APPDIR}/AppRun" << 'APPRUN_EOF' +#!/bin/sh +# apprun - entrypoint for jupyterlab AppImage +SELF="$(readlink -f "$0")" +HERE="$(dirname "$SELF")" +CONDA="${HERE}/usr/lib/miniforge" +export PYTHONHOME="${CONDA}" +export PATH="${CONDA}/bin:${PATH}" +export LD_LIBRARY_PATH="${CONDA}/lib:${LD_LIBRARY_PATH}" +exec "${CONDA}/bin/python3" "${CONDA}/bin/jupyter" "$@" +APPRUN_EOF +chmod +x "${APPDIR}/AppRun" + +# - .desktop file is required by the AppImage spec +cat > "${APPDIR}/${PKGNAME}.desktop" << DESKTOP_EOF +[Desktop Entry] +Name=JupyterLab +Comment=JupyterLab - Next-Generation Notebook Interface +Exec=jupyterlab +Icon=jupyterlab +Type=Application +Categories=Development;Science;Education; +DESKTOP_EOF + +# - fetch icon: prefer installed package assets, then a remote url, +# - then fall back to generating a minimal valid png using python stdlib +printf '==> fetching icon...\n' +ICON_PATH="${APPDIR}/usr/share/icons/hicolor/512x512/apps/${PKGNAME}.png" + +# - find the largest png bundled with the jupyterlab package itself +_pkg_icon="$("${CONDA_DIR}/bin/python3" -c " +import os, jupyterlab +base = os.path.dirname(jupyterlab.__file__) +pngs = [] +for root, dirs, files in os.walk(base): + for f in files: + if f.endswith('.png'): + p = os.path.join(root, f) + pngs.append((os.path.getsize(p), p)) +pngs.sort(reverse=True) +print(pngs[0][1] if pngs else '') +" 2>/dev/null)" + +if [ -n "$_pkg_icon" ]; then + cp "$_pkg_icon" "$ICON_PATH" +elif curl -fsSL \ + -o "$ICON_PATH" \ + "https://raw.githubusercontent.com/jupyter/design/master/logos/Square%20Logo/squarelogo-greytext-orangebody-greytextontop.png" \ + 2>/dev/null; then + printf ' icon downloaded from jupyter/design repo\n' +else + # - generate a 128x128 solid png in jupyter orange (#f37726) using only python stdlib + printf ' generating placeholder icon (jupyter orange)...\n' + "${CONDA_DIR}/bin/python3" -c " +import struct, zlib, sys + +def chunk(name, data): + crc = zlib.crc32(name + data) & 0xffffffff + return struct.pack('>I', len(data)) + name + data + struct.pack('>I', crc) + +w, h, r, g, b = 128, 128, 0xf3, 0x77, 0x26 +raw = (b'\x00' + bytes([r, g, b] * w)) * h +png = ( + b'\x89PNG\r\n\x1a\n' + + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0)) + + chunk(b'IDAT', zlib.compress(raw)) + + chunk(b'IEND', b'') +) +with open(sys.argv[1], 'wb') as f: + f.write(png) +" "$ICON_PATH" +fi + +# - appimage spec also requires the icon at the AppDir root +cp "$ICON_PATH" "${APPDIR}/${PKGNAME}.png" + +# - download appimagetool (itself an AppImage; APPIMAGE_EXTRACT_AND_RUN avoids FUSE dependency) +printf '==> downloading appimagetool (%s)...\n' "$UNAME_ARCH" +curl -fL --progress-bar -o "${WORKDIR}/appimagetool" "$APPIMAGETOOL_URL" +chmod +x "${WORKDIR}/appimagetool" + +# - build the AppImage +# - APPIMAGE_EXTRACT_AND_RUN=1 lets appimagetool run without FUSE (required in Docker) +# - ARCH must be set so appimagetool embeds the correct ELF architecture tag +OUTPUT="${OUTPUT_DIR}/${PKGNAME}-${PKGVER}-${UNAME_ARCH}.AppImage" +printf '==> building AppImage...\n' +ARCH="$UNAME_ARCH" APPIMAGE_EXTRACT_AND_RUN=1 \ + "${WORKDIR}/appimagetool" --comp gzip \ + "${APPDIR}" "${OUTPUT}" + +chmod +x "${OUTPUT}" + +printf '\n==> done!\n' +printf ' output: %s\n\n' "$OUTPUT" +printf 'usage:\n' +printf ' regular system: ./%s-%s-%s.AppImage lab\n' "$PKGNAME" "$PKGVER" "$UNAME_ARCH" +printf ' docker/no-fuse: APPIMAGE_EXTRACT_AND_RUN=1 ./%s-%s-%s.AppImage lab\n' "$PKGNAME" "$PKGVER" "$UNAME_ARCH" +printf '\nmove %s to /opt/jupyterlab-appimage/ within your environment and use the jupyter wrapper script to run it\n\n' "$OUTPUT" |
