Stacks a Colab-like JupyterLab and Studio experience on top of the
existing Blackwell image. Additive only: the training stack, CUDA/torch
pinning, and the Studio/JupyterLab/sshd service trio are unchanged.
JupyterLab labextension (prebuilt in a throwaway builder stage, so the
runtime image stays Node-free):
- Unsloth Dark (Monokai) theme, adaptive light/dark by system preference
- Colab-style ArrowDown/Up cell navigation
- top-bar Unsloth logo (stock Jupyter logo disabled and locked)
- #@title lines render as collapsible Heading-2 form bars
- Ctrl+A in a cell output selects only that output, not the whole
notebook (the old behaviour ran notebook:select-all and was laggy)
- right activity bar hidden by default
- overrides.json: per-cell run button without auto-advance, labeled
Restart and Run All, windowing off so collapsing an output does not
snap to the cell top, news/update prompts suppressed
Studio and login branding: Unsloth favicon, page logo, and a dark
Unsloth login page that rotates through the curated Studio sloth
stickers (fail-soft to the logo).
Notebook organization and Colab compatibility (base image):
- categorized folder view built from relative symlinks mirroring the
README sections, rebuilt each boot; real .ipynb files never moved,
and the symlink tree is invisible to the sync state machine
- AMD-* notebooks shown only on an AMD/HIP host (autodetected)
- Docker-only strip of the Colab "Run all on Colab" intro sentence
from unedited notebooks (upstream notebooks unchanged)
- hoist %%capture above a leading #@title form so the cell runs
- the per-cell transformers-sidecar log is silent unless
UNSLOTH_ENABLE_LOGGING=1
Dependency pinning and naming: the curated notebook extras are pinned to
their resolved versions for reproducible rebuilds; decord is split into
its own fail-soft install (no aarch64 wheel). The lean base image is
renamed from :base to :core.
Adds tests/validate_studio_features.py, a static self-test for the
labextension plugins, overrides keys, and branding wiring.
251 lines
11 KiB
Bash
251 lines
11 KiB
Bash
#!/usr/bin/env bash
|
|
# Populate and refresh /workspace/unsloth-notebooks from unslothai/notebooks.
|
|
#
|
|
# The image bakes a read-only template at /opt/unsloth-notebooks so the
|
|
# notebooks are present in JupyterLab instantly and offline. On boot this script
|
|
# copies the template into /workspace/unsloth-notebooks (first run only) and then
|
|
# best-effort refreshes from GitHub when upstream has actually advanced.
|
|
#
|
|
# The user's edits ALWAYS win. We remember the content hash of every file we
|
|
# wrote; on refresh a file whose current hash differs from what we last wrote is
|
|
# treated as user-modified and is left untouched. So a refresh only updates files
|
|
# the user has not changed and adds new ones -- it never clobbers an edited
|
|
# notebook and never produces merge conflicts.
|
|
#
|
|
# Opt-out / tuning (all optional):
|
|
# UNSLOTH_SKIP_NOTEBOOK_SYNC=1 do nothing (no populate, no refresh)
|
|
# UNSLOTH_SKIP_NOTEBOOK_REFRESH=1 populate from the baked template only;
|
|
# never touch the network
|
|
# UNSLOTH_KEEP_DELETED_NOTEBOOKS=1 do not restore notebooks the user deleted
|
|
# (default: deleted files are healed back)
|
|
# UNSLOTH_NOTEBOOKS_DIR=<path> target dir (default /workspace/unsloth-notebooks)
|
|
# UNSLOTH_NOTEBOOKS_REPO=<url> source repo (default unslothai/notebooks)
|
|
# UNSLOTH_NOTEBOOK_FETCH_TIMEOUT=N seconds for each network op (default 60)
|
|
# UNSLOTH_SKIP_NOTEBOOK_VIEW=1 do not build the categorized folder view
|
|
# UNSLOTH_NOTEBOOKS_VIEW_DIR=<path> categorized view dir
|
|
# (default "/workspace/Unsloth Notebooks")
|
|
# UNSLOTH_NB_GPU=amd|cuda force AMD-* notebook visibility (default:
|
|
# autodetect; AMD-* shown only on AMD/HIP)
|
|
# UNSLOTH_KEEP_COLAB_INTRO=1 keep the Colab "Run all on Colab" sentence
|
|
# (default: strip it for the Docker image)
|
|
set -u
|
|
|
|
TEMPLATE="${UNSLOTH_NOTEBOOKS_TEMPLATE:-/opt/unsloth-notebooks}"
|
|
DEST="${UNSLOTH_NOTEBOOKS_DIR:-/workspace/unsloth-notebooks}"
|
|
REMOTE="${UNSLOTH_NOTEBOOKS_REPO:-https://github.com/unslothai/notebooks}"
|
|
STATE="$DEST/.unsloth_sync_state" # "sha256 relpath" of what we last wrote
|
|
SYNCED="$DEST/.unsloth_sync_commit" # upstream commit we last synced to
|
|
TIMEOUT="${UNSLOTH_NOTEBOOK_FETCH_TIMEOUT:-60}"
|
|
|
|
# Helper that compares the *content* (the middle, ignoring the auto-generated
|
|
# install header / announcements / footer) of two notebooks. Used so a refresh
|
|
# doesn't rewrite an untouched notebook when only that boilerplate moved
|
|
# upstream. Resolved from an explicit override, then PATH, then a sibling file.
|
|
PYBIN="$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true)"
|
|
SIG_HELPER="${UNSLOTH_NB_SIG_HELPER:-}"
|
|
if [ -z "$SIG_HELPER" ]; then
|
|
if command -v unsloth-nb-content-sig >/dev/null 2>&1; then
|
|
SIG_HELPER="$(command -v unsloth-nb-content-sig)"
|
|
else
|
|
_self_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
|
[ -n "$_self_dir" ] && [ -f "$_self_dir/unsloth_nb_content_sig.py" ] \
|
|
&& SIG_HELPER="$_self_dir/unsloth_nb_content_sig.py"
|
|
fi
|
|
fi
|
|
|
|
# Same resolution (override -> PATH -> sibling file) for the categorized-view
|
|
# builder and the Docker-only Colab-intro stripper.
|
|
_self_dir="${_self_dir:-$(cd "$(dirname "$0")" 2>/dev/null && pwd)}"
|
|
VIEW_HELPER="${UNSLOTH_NB_VIEW_HELPER:-}"
|
|
if [ -z "$VIEW_HELPER" ]; then
|
|
if command -v unsloth-nb-view >/dev/null 2>&1; then
|
|
VIEW_HELPER="$(command -v unsloth-nb-view)"
|
|
elif [ -n "$_self_dir" ] && [ -f "$_self_dir/unsloth_nb_view.py" ]; then
|
|
VIEW_HELPER="$_self_dir/unsloth_nb_view.py"
|
|
fi
|
|
fi
|
|
STRIP_HELPER="${UNSLOTH_NB_STRIP_HELPER:-}"
|
|
if [ -z "$STRIP_HELPER" ]; then
|
|
if command -v unsloth-nb-strip-colab >/dev/null 2>&1; then
|
|
STRIP_HELPER="$(command -v unsloth-nb-strip-colab)"
|
|
elif [ -n "$_self_dir" ] && [ -f "$_self_dir/unsloth_nb_strip_colab.py" ]; then
|
|
STRIP_HELPER="$_self_dir/unsloth_nb_strip_colab.py"
|
|
fi
|
|
fi
|
|
|
|
# True only when BOTH are .ipynb, the helper is usable, and it reports the
|
|
# non-boilerplate middle is identical (so only the header/footer changed).
|
|
# Any failure returns false, so the caller falls back to a normal refresh.
|
|
middle_unchanged() {
|
|
case "$1" in *.ipynb) : ;; *) return 1 ;; esac
|
|
[ -n "$PYBIN" ] && [ -n "$SIG_HELPER" ] || return 1
|
|
[ "${UNSLOTH_NOTEBOOK_BODY_AWARE:-1}" = "1" ] || return 1
|
|
[ "$("$PYBIN" "$SIG_HELPER" "$1" "$2" 2>/dev/null)" = "SAME" ] || return 1
|
|
return 0
|
|
}
|
|
|
|
[ "${UNSLOTH_SKIP_NOTEBOOK_SYNC:-0}" = "1" ] && exit 0
|
|
[ -d "$TEMPLATE" ] || exit 0
|
|
mkdir -p "$DEST" 2>/dev/null || exit 0
|
|
|
|
hash_of() { sha256sum "$1" 2>/dev/null | cut -d' ' -f1; }
|
|
|
|
# --- categorized folder view + Docker-only Colab cleanups --------------------
|
|
# AMD/HIP detection: AMD-*.ipynb are shown only on an AMD GPU. UNSLOTH_NB_GPU
|
|
# forces it (amd|cuda); otherwise probe nvidia-smi then the ROCm tools.
|
|
nb_gpu_is_amd() {
|
|
case "${UNSLOTH_NB_GPU:-}" in
|
|
amd|AMD|hip|HIP|rocm|ROCm|ROCM) return 0 ;;
|
|
cuda|CUDA|nvidia|NVIDIA|nv|NV) return 1 ;;
|
|
esac
|
|
if command -v nvidia-smi >/dev/null 2>&1 \
|
|
&& nvidia-smi -L 2>/dev/null | grep -q '^GPU'; then
|
|
return 1
|
|
fi
|
|
if command -v rocm-smi >/dev/null 2>&1 || command -v rocminfo >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
return 1 # default: treat as non-AMD (hide AMD-* notebooks)
|
|
}
|
|
|
|
# Rebuild the sibling symlink VIEW (categorized folders mirroring the README
|
|
# headers) from scratch. Symlinks live OUTSIDE $DEST, so the sync state machine
|
|
# (which walks `find -type f`, skipping symlinks) never sees them.
|
|
build_categorized_view() {
|
|
[ "${UNSLOTH_SKIP_NOTEBOOK_VIEW:-0}" = "1" ] && return 0
|
|
[ -n "$PYBIN" ] && [ -n "$VIEW_HELPER" ] || return 0
|
|
[ -d "$DEST/nb" ] || return 0
|
|
_view="${UNSLOTH_NOTEBOOKS_VIEW_DIR:-/workspace/Unsloth Notebooks}"
|
|
if nb_gpu_is_amd; then
|
|
"$PYBIN" "$VIEW_HELPER" "$DEST" "$_view" --amd 2>/dev/null || true
|
|
else
|
|
"$PYBIN" "$VIEW_HELPER" "$DEST" "$_view" 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
# Strip the Colab-only "Run all on Colab" sentence from notebooks WE own and the
|
|
# user has not edited (STATE-aware), updating their recorded hashes in place.
|
|
strip_colab_intros() {
|
|
[ "${UNSLOTH_KEEP_COLAB_INTRO:-0}" = "1" ] && return 0
|
|
[ -n "$PYBIN" ] && [ -n "$STRIP_HELPER" ] || return 0
|
|
[ -f "$STATE" ] || return 0
|
|
"$PYBIN" "$STRIP_HELPER" --state "$STATE" --dest "$DEST" 2>/dev/null || true
|
|
}
|
|
|
|
# Apply both on EVERY exit after the basic guards, so the view + cleanups also
|
|
# run on the common "nothing to refresh" / offline paths. Both are idempotent.
|
|
finalize() { strip_colab_intros; build_categorized_view; }
|
|
trap finalize EXIT
|
|
|
|
# Record "<hash> <relpath>" for every file currently under DEST (skip metadata).
|
|
record_state() {
|
|
: > "$STATE.tmp" 2>/dev/null || return 0
|
|
( cd "$DEST" && find . -type f -print0 ) | while IFS= read -r -d '' rel; do
|
|
rel="${rel#./}"
|
|
case "$rel" in
|
|
.unsloth_sync_state|.unsloth_sync_commit) continue ;;
|
|
esac
|
|
printf '%s %s\n' "$(hash_of "$DEST/$rel")" "$rel" >> "$STATE.tmp"
|
|
done
|
|
mv "$STATE.tmp" "$STATE" 2>/dev/null || rm -f "$STATE.tmp"
|
|
}
|
|
|
|
# 1) First-boot populate from the baked template (instant, works offline).
|
|
if [ ! -f "$STATE" ]; then
|
|
( cd "$TEMPLATE" && find . -type f -print0 ) | while IFS= read -r -d '' rel; do
|
|
rel="${rel#./}"
|
|
case "$rel" in .unsloth_template_commit) continue ;; esac
|
|
mkdir -p "$DEST/$(dirname "$rel")" 2>/dev/null || true
|
|
cp -a "$TEMPLATE/$rel" "$DEST/$rel" 2>/dev/null || true
|
|
done
|
|
record_state
|
|
cp -a "$TEMPLATE/.unsloth_template_commit" "$SYNCED" 2>/dev/null || true
|
|
echo "[unsloth-nb] notebooks ready at $DEST"
|
|
fi
|
|
|
|
# 1b) Every-boot OFFLINE restore of deleted notebooks. A file we previously wrote
|
|
# that the user has since DELETED is restored from the baked template -- works
|
|
# with no network and even when upstream has not advanced. Files that still exist
|
|
# (edited or not) are never touched, so this cannot resurrect or clobber an edit;
|
|
# the GitHub refresh below then bumps any restored file to the latest upstream.
|
|
# The restored file's recorded hash is reset to the template's so the refresh
|
|
# treats it as pristine (not as a user edit). Opt out with
|
|
# UNSLOTH_KEEP_DELETED_NOTEBOOKS=1 (for users who prune notebooks on purpose).
|
|
if [ -f "$STATE" ] && [ "${UNSLOTH_KEEP_DELETED_NOTEBOOKS:-0}" != "1" ]; then
|
|
restored=0
|
|
RS_TMP="$(mktemp)"
|
|
while IFS= read -r line; do
|
|
h="${line%% *}"; rel="${line#* }"
|
|
if [ -n "$rel" ] && [ "$rel" != "$line" ] \
|
|
&& [ ! -e "$DEST/$rel" ] && [ -f "$TEMPLATE/$rel" ]; then
|
|
mkdir -p "$DEST/$(dirname "$rel")" 2>/dev/null || true
|
|
if cp -a "$TEMPLATE/$rel" "$DEST/$rel" 2>/dev/null; then
|
|
printf '%s %s\n' "$(hash_of "$DEST/$rel")" "$rel" >> "$RS_TMP"
|
|
restored=$((restored + 1))
|
|
continue
|
|
fi
|
|
fi
|
|
printf '%s\n' "$line" >> "$RS_TMP"
|
|
done < "$STATE"
|
|
mv "$RS_TMP" "$STATE" 2>/dev/null || rm -f "$RS_TMP"
|
|
[ "$restored" -gt 0 ] \
|
|
&& echo "[unsloth-nb] restored $restored deleted notebook(s) from the baked set"
|
|
fi
|
|
|
|
# 2) Best-effort GitHub refresh -- only when upstream has advanced. Edits win.
|
|
[ "${UNSLOTH_SKIP_NOTEBOOK_REFRESH:-0}" = "1" ] && exit 0
|
|
command -v git >/dev/null 2>&1 || exit 0
|
|
command -v sha256sum >/dev/null 2>&1 || exit 0
|
|
|
|
last="$(cat "$SYNCED" 2>/dev/null || true)"
|
|
remote="$(timeout "$TIMEOUT" git ls-remote "$REMOTE" HEAD 2>/dev/null | cut -f1)"
|
|
[ -z "$remote" ] && exit 0 # offline / unreachable -> keep what we have
|
|
[ "$remote" = "$last" ] && exit 0 # nothing new since last sync -> done
|
|
|
|
TMP="$(mktemp -d)"
|
|
if ! timeout "$TIMEOUT" git clone -q --depth 1 "$REMOTE" "$TMP" 2>/dev/null; then
|
|
rm -rf "$TMP"; exit 0 # network died mid-way -> keep what we have
|
|
fi
|
|
|
|
declare -A LAST
|
|
if [ -f "$STATE" ]; then
|
|
while read -r h p; do
|
|
[ -n "${p:-}" ] && LAST["$p"]="$h"
|
|
done < "$STATE"
|
|
fi
|
|
|
|
TMPSTATE="$(mktemp)"
|
|
updated=0; kept=0; unchanged=0
|
|
while IFS= read -r -d '' f; do
|
|
rel="${f#"$TMP"/}"
|
|
case "$rel" in .git|.git/*) continue ;; esac
|
|
dst="$DEST/$rel"
|
|
if [ -e "$dst" ]; then
|
|
rec="${LAST[$rel]:-}"
|
|
if [ -n "$rec" ] && [ "$(hash_of "$dst")" != "$rec" ]; then
|
|
# User changed this file since we wrote it -> keep theirs, keep marker.
|
|
printf '%s %s\n' "$rec" "$rel" >> "$TMPSTATE"
|
|
kept=$((kept + 1))
|
|
continue
|
|
fi
|
|
if [ -n "$rec" ] && middle_unchanged "$dst" "$f"; then
|
|
# Untouched notebook whose only upstream change is the install
|
|
# header / announcements / footer. The tutorial body is identical,
|
|
# so don't churn the user's file -- keep it and its marker as-is.
|
|
printf '%s %s\n' "$rec" "$rel" >> "$TMPSTATE"
|
|
unchanged=$((unchanged + 1))
|
|
continue
|
|
fi
|
|
fi
|
|
mkdir -p "$(dirname "$dst")" 2>/dev/null || true
|
|
if cp -a "$f" "$dst" 2>/dev/null; then
|
|
printf '%s %s\n' "$(hash_of "$dst")" "$rel" >> "$TMPSTATE"
|
|
updated=$((updated + 1))
|
|
fi
|
|
done < <(find "$TMP" -type f -print0)
|
|
|
|
mv "$TMPSTATE" "$STATE" 2>/dev/null || rm -f "$TMPSTATE"
|
|
echo "$remote" > "$SYNCED" 2>/dev/null || true
|
|
rm -rf "$TMP"
|
|
echo "[unsloth-nb] notebooks refreshed from GitHub: $updated updated, $kept kept (your edits), $unchanged kept (only header/footer changed upstream)"
|
|
exit 0
|