summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorkj_sh6042026-05-19 20:59:16 -0400
committerkj_sh6042026-05-19 20:59:16 -0400
commit7e033c9d81a3513c40db9786cb61715708b8d61d (patch)
treee38ab3c1ccbea96b304f1d0a8ece7e5368e76676
parent0f1c1a85ea6cfc75118e3469576977d6e05365df (diff)
refactor: build-jupyterlab.sh
-rwxr-xr-xbuild-jupyterlab.sh262
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"