docker: Colab-grade JupyterLab and Studio UX for the Blackwell image
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.
This commit is contained in:
parent
08f9b67f60
commit
c9ff52ba04
31 changed files with 1824 additions and 16 deletions
|
|
@ -14,3 +14,19 @@
|
|||
!unsloth_run.py
|
||||
!unsloth_sync_notebooks.sh
|
||||
!unsloth_nb_content_sig.py
|
||||
!unsloth_nb_view.py
|
||||
!unsloth_nb_strip_colab.py
|
||||
!unsloth_colab_compat.py
|
||||
!jupyter
|
||||
!jupyter/overrides.json
|
||||
!jupyter/favicon.ico
|
||||
!jupyter/logo.png
|
||||
!jupyter/login.html
|
||||
!jupyter/unsloth_labext
|
||||
!jupyter/unsloth_labext/package.json
|
||||
!jupyter/unsloth_labext/tsconfig.json
|
||||
!jupyter/unsloth_labext/.yarnrc.yml
|
||||
!jupyter/unsloth_labext/src
|
||||
!jupyter/unsloth_labext/src/**
|
||||
!jupyter/unsloth_labext/style
|
||||
!jupyter/unsloth_labext/style/**
|
||||
|
|
|
|||
|
|
@ -285,18 +285,30 @@ RUN set -eux \
|
|||
# omegaconf TTS families + both NeMo-Gym RL notebooks' config objects
|
||||
# einx TTS codec tensor-rearrange (Llasa / Oute / Spark TTS)
|
||||
# librosa Whisper audio feature extraction (pairs with soundfile + torchcodec)
|
||||
# decord ERNIE-VL vision notebook video decode
|
||||
# ftfy Oute TTS text normalisation
|
||||
# decord (ERNIE-VL video decode) is installed separately below: it ships no
|
||||
# aarch64 wheel, so a hard install here would break the arm64 build.
|
||||
# librosa pulls numba/soxr/audioread; numba is already pinned >=0.65 (numpy 2.4
|
||||
# compatible) by the vLLM pass, so the resolve must NOT move torch/numpy/numba --
|
||||
# the assertion below fails the build loudly if it did.
|
||||
# Pinned (==) to the resolved, tested versions for reproducible rebuilds -- the
|
||||
# same convention as the cu128 core (torch/torchvision/torchaudio). Bump these
|
||||
# deliberately, not silently on the next build. Transitive deps of these are
|
||||
# captured by the full venv lockfile (docker/freeze.sh -> requirements.lock.txt).
|
||||
RUN ${VENV}/bin/uv pip install \
|
||||
--python ${VENV}/bin/python \
|
||||
jupyterlab notebook ipywidgets matplotlib \
|
||||
soundfile evaluate jiwer tensorboard langid easydict protobuf \
|
||||
omegaconf einx librosa decord ftfy \
|
||||
"jupyterlab==4.6.0" "notebook==7.6.0" "ipywidgets==8.1.8" "matplotlib==3.11.0" \
|
||||
"soundfile==0.14.0" "evaluate==0.4.6" "jiwer==4.0.0" "tensorboard==2.20.0" \
|
||||
"langid==1.1.6" "easydict==1.13" "protobuf==6.33.6" \
|
||||
"omegaconf==2.3.1" "einx==0.4.3" "librosa==0.11.0" "ftfy==6.3.1" \
|
||||
&& ${VENV}/bin/python -c "import torch, numpy, numba; from packaging.version import Version; assert torch.__version__.startswith('2.10.0'), torch.__version__; assert Version(numpy.__version__) >= Version('2.3'), numpy.__version__; assert Version(numba.__version__) >= Version('0.65'), numba.__version__; print('notebook-deps pins OK:', torch.__version__, numpy.__version__, numba.__version__)"
|
||||
|
||||
# decord (ERNIE-VL video decode) publishes wheels only for x86_64 / win_amd64,
|
||||
# so install it on its own and fail-soft: amd64 gets it; on arm64 the ERNIE-VL
|
||||
# video path is skipped rather than breaking the whole image build.
|
||||
RUN ${VENV}/bin/uv pip install --python ${VENV}/bin/python "decord==0.6.0" \
|
||||
|| echo ">> decord skipped (no matching wheel for ${TARGETARCH:-amd64}); ERNIE-VL video decode unavailable"
|
||||
|
||||
# Audio decode out of the box: the TTS/STT notebooks feed datasets' Audio
|
||||
# features, which decode through torchcodec. Three traps, all defended:
|
||||
# * version pairing: torchcodec 0.10 pairs with torch 2.10 (newer builds
|
||||
|
|
@ -647,19 +659,22 @@ RUN mkdir -p ${HF_HOME} ${TRITON_CACHE_DIR}
|
|||
# * unsloth-run: headless `unsloth-run <notebook|url>` that auto-picks the
|
||||
# sidecar and executes every cell -- the robust driven path.
|
||||
# ---------------------------------------------------------------------------
|
||||
COPY unsloth_nb_compat.py unsloth_pip_shim.py unsloth_ipython_startup.py unsloth_run.py unsloth_sync_notebooks.sh unsloth_nb_content_sig.py /opt/unsloth-nb/
|
||||
COPY unsloth_nb_compat.py unsloth_pip_shim.py unsloth_ipython_startup.py unsloth_run.py unsloth_sync_notebooks.sh unsloth_nb_content_sig.py unsloth_nb_view.py unsloth_nb_strip_colab.py unsloth_colab_compat.py /opt/unsloth-nb/
|
||||
RUN set -eux \
|
||||
&& SP=/opt/unsloth-venv/lib/python${PYTHON_VERSION}/site-packages \
|
||||
&& cp /opt/unsloth-nb/unsloth_nb_compat.py "$SP/unsloth_nb_compat.py" \
|
||||
&& chmod +x /opt/unsloth-nb/unsloth_pip_shim.py /opt/unsloth-nb/unsloth_run.py /opt/unsloth-nb/unsloth_sync_notebooks.sh /opt/unsloth-nb/unsloth_nb_content_sig.py \
|
||||
&& cp /opt/unsloth-nb/unsloth_colab_compat.py "$SP/unsloth_colab_compat.py" \
|
||||
&& chmod +x /opt/unsloth-nb/unsloth_pip_shim.py /opt/unsloth-nb/unsloth_run.py /opt/unsloth-nb/unsloth_sync_notebooks.sh /opt/unsloth-nb/unsloth_nb_content_sig.py /opt/unsloth-nb/unsloth_nb_view.py /opt/unsloth-nb/unsloth_nb_strip_colab.py \
|
||||
&& mkdir -p /opt/unsloth-nb/bin \
|
||||
&& for t in pip pip3 uv; do ln -sf /opt/unsloth-nb/unsloth_pip_shim.py /opt/unsloth-nb/bin/$t; done \
|
||||
&& ln -sf /opt/unsloth-nb/unsloth_run.py /usr/local/bin/unsloth-run \
|
||||
&& ln -sf /opt/unsloth-nb/unsloth_sync_notebooks.sh /usr/local/bin/unsloth-sync-notebooks \
|
||||
&& ln -sf /opt/unsloth-nb/unsloth_nb_content_sig.py /usr/local/bin/unsloth-nb-content-sig \
|
||||
&& ln -sf /opt/unsloth-nb/unsloth_nb_view.py /usr/local/bin/unsloth-nb-view \
|
||||
&& ln -sf /opt/unsloth-nb/unsloth_nb_strip_colab.py /usr/local/bin/unsloth-nb-strip-colab \
|
||||
&& mkdir -p /root/.ipython/profile_default/startup \
|
||||
&& cp /opt/unsloth-nb/unsloth_ipython_startup.py /root/.ipython/profile_default/startup/00-unsloth-nb.py \
|
||||
&& /opt/unsloth-venv/bin/python -c "import sys, glob; sys.path.insert(0, '$SP'); import unsloth_nb_compat; print('nb-compat OK; baked sidecars:', sorted(glob.glob('/opt/unsloth-venv/tf-sidecars/t_*')))"
|
||||
&& /opt/unsloth-venv/bin/python -c "import sys, glob; sys.path.insert(0, '$SP'); import unsloth_nb_compat, unsloth_colab_compat; print('nb-compat OK; baked sidecars:', sorted(glob.glob('/opt/unsloth-venv/tf-sidecars/t_*')))"
|
||||
# Shim dir AHEAD of the venv bin so `!pip`/`!uv` resolve to the shim, not the real tool.
|
||||
ENV PATH=/opt/unsloth-nb/bin:${PATH}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
# Full Unsloth image: base training stack + Studio + JupyterLab + sshd.
|
||||
#
|
||||
# This is the image published as docker.io/unsloth/unsloth:latest. It layers
|
||||
# Unsloth Studio on top of the lean base image (Dockerfile, published under
|
||||
# the `base` tags) and runs the same service trio as the previous production
|
||||
# This is the image published as docker.io/unsloth/unsloth:studio (and the
|
||||
# default :latest). It layers Unsloth Studio on top of the lean core image
|
||||
# (Dockerfile, published under the `core` tags) and runs the same service trio
|
||||
# as the previous production
|
||||
# image: Studio on 8000, JupyterLab on 8888, key-only sshd on 22.
|
||||
#
|
||||
# Build (local):
|
||||
|
|
@ -28,6 +29,22 @@
|
|||
# images always ship the same stack.
|
||||
|
||||
ARG BASE_IMAGE=unsloth-blackwell:test
|
||||
|
||||
# --- builder stage: prebuild the Unsloth JupyterLab extension -----------------
|
||||
# Builds the named "Unsloth Dark" (Monokai) theme + the Colab-style Down/Up
|
||||
# cell-navigation keymap. Node lives ONLY in this throwaway stage; the final
|
||||
# image copies just the prebuilt static labextension, so the runtime stays
|
||||
# Node-free. Uses the base image's bundled jlpm + jupyterlab (version-matched).
|
||||
FROM ${BASE_IMAGE} AS labext-builder
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nodejs npm git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY jupyter/unsloth_labext /opt/labext-src
|
||||
RUN cd /opt/labext-src \
|
||||
&& /opt/unsloth-venv/bin/jlpm install \
|
||||
&& /opt/unsloth-venv/bin/jlpm build:prod
|
||||
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
# Studio source ref to clone. Defaults to `main`, but a CI publish pipeline
|
||||
|
|
@ -170,6 +187,43 @@ COPY fetch_llama_prebuilt.py /usr/local/lib/unsloth/fetch_llama_prebuilt.py
|
|||
# Optional public Cloudflare tunnel for JupyterLab (UNSLOTH_JUPYTER_CLOUDFLARE=1,
|
||||
# or `unsloth-jupyter-tunnel --force`); supervisord runs it as jupyter-cloudflare.
|
||||
COPY unsloth_jupyter_tunnel.sh /usr/local/bin/unsloth-jupyter-tunnel
|
||||
# JupyterLab defaults baked for every container: the named "Unsloth Dark"
|
||||
# (Monokai) theme with adaptive light/dark by system preference, a per-cell run
|
||||
# button that does NOT auto-advance, a labeled "Restart & Run All", windowing
|
||||
# disabled so collapsing a long output does not snap to the cell top,
|
||||
# ArrowDown/Up jumping to the TOP of the next/previous cell, and the official
|
||||
# Jupyter "get notified about news" prompt suppressed (fetchNews/checkForUpdates
|
||||
# off). overrides.json is the system-wide settings override (read from the base
|
||||
# venv's share/jupyter/lab/settings); the theme + keymap + Unsloth top-bar logo
|
||||
# ship as the prebuilt labextension built in the labext-builder stage above.
|
||||
COPY jupyter/overrides.json /opt/unsloth-venv/share/jupyter/lab/settings/overrides.json
|
||||
COPY --from=labext-builder /opt/labext-src/unsloth-jupyterlab/labextension /opt/unsloth-venv/share/jupyter/labextensions/unsloth-jupyterlab
|
||||
# Unsloth branding (all served by jupyter_server, so applied to its site-packages
|
||||
# the same way): replace the browser-tab favicon and the page logo with the
|
||||
# Unsloth logo, and brand the login screen (dark Unsloth-themed login.html).
|
||||
# Also disable + lock the stock top-left Jupyter logo plugin so the Unsloth logo
|
||||
# widget shipped by the labextension is the only one rendered in the top bar
|
||||
# (lock keeps users from re-enabling it in the UI).
|
||||
COPY jupyter/favicon.ico /tmp/unsloth-branding/favicon.ico
|
||||
COPY jupyter/logo.png /tmp/unsloth-branding/logo.png
|
||||
COPY jupyter/login.html /tmp/unsloth-branding/login.html
|
||||
COPY jupyter/install_sloth_stickers.py /tmp/unsloth-branding/install_sloth_stickers.py
|
||||
RUN JS="$(/opt/unsloth-venv/bin/python -c 'import os, jupyter_server; print(os.path.dirname(jupyter_server.__file__))')" \
|
||||
&& for n in favicon.ico favicon-notebook.ico favicon-file.ico favicon-terminal.ico; do \
|
||||
cp /tmp/unsloth-branding/favicon.ico "${JS}/static/favicons/${n}"; \
|
||||
done \
|
||||
&& cp /tmp/unsloth-branding/logo.png "${JS}/static/logo/logo.png" \
|
||||
&& cp /tmp/unsloth-branding/login.html "${JS}/templates/login.html" \
|
||||
# Copy the curated Studio sloth stickers the login page rotates through into
|
||||
# jupyter_server's static dir (sloth/NN.png). Fail-soft: if the Studio
|
||||
# public folder ever moves, login.html's onerror falls back to the logo.
|
||||
&& /opt/unsloth-venv/bin/python /tmp/unsloth-branding/install_sloth_stickers.py \
|
||||
--src "${UNSLOTH_STUDIO_HOME}/src/studio/frontend/public/Sloth emojis" \
|
||||
--dest "${JS}/static/sloth" \
|
||||
|| echo ">> sloth stickers not installed (login falls back to the Unsloth logo)" \
|
||||
&& rm -rf /tmp/unsloth-branding \
|
||||
&& /opt/unsloth-venv/bin/jupyter labextension disable @jupyterlab/application-extension:logo \
|
||||
&& /opt/unsloth-venv/bin/jupyter labextension lock @jupyterlab/application-extension:logo
|
||||
RUN chmod +x /usr/local/bin/unsloth-studio-launch \
|
||||
/usr/local/bin/unsloth-studio-update \
|
||||
/usr/local/bin/unsloth-llama-update \
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
$ErrorActionPreference = "Continue"
|
||||
$IMAGE = if ($env:IMAGE) { $env:IMAGE } else { "unsloth/unsloth:latest" }
|
||||
$BASE_IMAGE = if ($env:BASE_IMAGE) { $env:BASE_IMAGE } else { "unsloth/unsloth:base" }
|
||||
$BASE_IMAGE = if ($env:BASE_IMAGE) { $env:BASE_IMAGE } else { "unsloth/unsloth:core" }
|
||||
$GPUS = if ($env:GPUS) { $env:GPUS } else { "auto" }
|
||||
$PORT_STUDIO = if ($env:PORT_STUDIO) { $env:PORT_STUDIO } else { 18000 }
|
||||
$PORT_JUPYTER = if ($env:PORT_JUPYTER) { $env:PORT_JUPYTER } else { 18888 }
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
# Studio chat / Jupyter / GGUF tooling still validate.
|
||||
#
|
||||
# Env overrides: IMAGE (default unsloth/unsloth:latest)
|
||||
# BASE_IMAGE (default unsloth/unsloth:base)
|
||||
# BASE_IMAGE (default unsloth/unsloth:core)
|
||||
# GPUS=all|none|0|0,1 (default: auto-detect)
|
||||
# PORT_STUDIO=18000 PORT_JUPYTER=18888
|
||||
# WORK=~/unsloth_docker_test (logs)
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
set -uo pipefail
|
||||
|
||||
IMAGE="${IMAGE:-unsloth/unsloth:latest}"
|
||||
BASE_IMAGE="${BASE_IMAGE:-unsloth/unsloth:base}"
|
||||
BASE_IMAGE="${BASE_IMAGE:-unsloth/unsloth:core}"
|
||||
GPUS="${GPUS:-auto}"
|
||||
PORT_STUDIO="${PORT_STUDIO:-18000}"
|
||||
PORT_JUPYTER="${PORT_JUPYTER:-18888}"
|
||||
|
|
|
|||
BIN
docker/jupyter/favicon.ico
Normal file
BIN
docker/jupyter/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
78
docker/jupyter/install_sloth_stickers.py
Normal file
78
docker/jupyter/install_sloth_stickers.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Install the Unsloth Studio sloth stickers for the JupyterLab login screen.
|
||||
|
||||
The branded login page (login.html) shows a different sloth sticker on each
|
||||
visit, the same curated set Studio offers as profile avatars. The PNGs live in
|
||||
the Studio frontend (`studio/frontend/public/Sloth emojis/`), which is present
|
||||
in the studio image after install.sh runs. This copies the curated subset into
|
||||
jupyter_server's static dir as `sloth/01.png .. sloth/20.png` so the template
|
||||
can reference stable, space-free, auth-free URLs via `static_url(...)`.
|
||||
|
||||
Usage:
|
||||
install_sloth_stickers.py --src "<Sloth emojis dir>" --dest "<static>/sloth"
|
||||
|
||||
Fail-soft: a missing source file is skipped (login.html's onerror falls back to
|
||||
the Unsloth logo), and the script still exits 0 as long as at least one sticker
|
||||
was installed. Stdlib only.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
# Curated, in display order -> NN.png. Mirrors Studio's SLOTH_AVATARS
|
||||
# (frontend/src/features/profile/sloth-avatars.ts): the square, low-whitespace
|
||||
# stickers that frame cleanly. Kept in sync by hand; missing names are skipped.
|
||||
CURATED = [
|
||||
"large sloth yay.png",
|
||||
"large sloth heart.png",
|
||||
"large sloth wave.png",
|
||||
"large sloth thumbs.png",
|
||||
"large sloth cheeky.png",
|
||||
"large sloth glasses.png",
|
||||
"large sloth fire.png",
|
||||
"large sloth drink.png",
|
||||
"large sloth sad.png",
|
||||
"Large sloth Question mark.png",
|
||||
"sloth shy large.png",
|
||||
"sloth shock large.png",
|
||||
"sloth sir large.png",
|
||||
"sloth huglove large.png",
|
||||
"sloth headphones.png",
|
||||
"sloth pc square.png",
|
||||
"sloth on phone.png",
|
||||
"sloth magnify final.png",
|
||||
"Sloth loca pc.png",
|
||||
"UnSloth GPU Front square.png",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--src", required=True, help="Studio 'Sloth emojis' dir")
|
||||
parser.add_argument("--dest", required=True, help="output dir (static/sloth)")
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.dest, exist_ok=True)
|
||||
installed = 0
|
||||
for index, name in enumerate(CURATED, start=1):
|
||||
source = os.path.join(args.src, name)
|
||||
target = os.path.join(args.dest, "%02d.png" % index)
|
||||
if not os.path.isfile(source):
|
||||
print(" skip (missing): %s" % name)
|
||||
continue
|
||||
try:
|
||||
shutil.copyfile(source, target)
|
||||
installed += 1
|
||||
except OSError as error:
|
||||
print(" skip (%s): %s" % (error, name))
|
||||
|
||||
print("installed %d/%d sloth stickers into %s" % (installed, len(CURATED), args.dest))
|
||||
# Non-fatal: the login page degrades to the logo if none were installed, but
|
||||
# a totally empty copy usually means a wrong --src, so signal that.
|
||||
return 0 if installed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
97
docker/jupyter/login.html
Normal file
97
docker/jupyter/login.html
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
{# Unsloth-branded JupyterLab login page. Overwrites jupyter_server's default
|
||||
login.html (same overwrite pattern as the favicon/logo). Extends the stock
|
||||
page.html so favicon (already the Unsloth icon) and form plumbing stay intact;
|
||||
we override the title, hide the stock header, and render a dark centered card
|
||||
matching the "Unsloth Dark" (Monokai) theme. The card logo reads
|
||||
static/logo/logo.png, which the image build replaces with the Unsloth logo. #}
|
||||
{% extends "page.html" %}
|
||||
|
||||
{% block title %}Unsloth{% endblock %}
|
||||
|
||||
{% block stylesheet %}
|
||||
<style>
|
||||
html, body {
|
||||
background: hsl(70, 8%, 12%) !important;
|
||||
color: hsl(60, 30%, 96%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
/* Hide the stock top header (jupyter_server's index.css uses a higher-
|
||||
specificity selector, so force it); the centered card carries the brand. */
|
||||
#header, .header-bar { display: none !important; }
|
||||
#site { display: flex; justify-content: center; align-items: flex-start; }
|
||||
.unsloth-login-card {
|
||||
margin-top: 11vh;
|
||||
background: hsl(70, 8%, 18%);
|
||||
border: 1px solid hsl(70, 8%, 28%);
|
||||
border-radius: 12px;
|
||||
padding: 38px 40px 32px;
|
||||
width: 360px;
|
||||
max-width: 90vw;
|
||||
text-align: center;
|
||||
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.unsloth-login-card img.logo { height: 72px; width: auto; margin-bottom: 14px; }
|
||||
/* A random Unsloth Studio sloth sticker, shown like the Studio login screen. */
|
||||
.unsloth-login-card img.sloth {
|
||||
height: 104px; width: 104px; object-fit: contain;
|
||||
margin: 2px auto 10px; display: block;
|
||||
}
|
||||
.unsloth-login-card h1 { font-size: 22px; margin: 0 0 4px; font-weight: 700; }
|
||||
.unsloth-login-card p.sub { color: hsl(60, 8%, 64%); margin: 0 0 24px; font-size: 14px; }
|
||||
.unsloth-login-card label {
|
||||
display: block; text-align: left; font-size: 13px;
|
||||
margin-bottom: 6px; color: hsl(60, 8%, 76%);
|
||||
}
|
||||
.unsloth-login-card input[type="password"] {
|
||||
width: 100%; box-sizing: border-box; padding: 10px 12px;
|
||||
border-radius: 8px; border: 1px solid hsl(70, 8%, 32%);
|
||||
background: hsl(70, 8%, 13%); color: inherit; font-size: 14px; margin-bottom: 18px;
|
||||
}
|
||||
.unsloth-login-card input[type="password"]:focus {
|
||||
outline: none; border-color: hsl(160, 55%, 48%);
|
||||
}
|
||||
.unsloth-login-card button {
|
||||
width: 100%; padding: 10px 12px; border-radius: 8px; border: none;
|
||||
background: hsl(160, 55%, 42%); color: #fff; font-weight: 600; font-size: 14px; cursor: pointer;
|
||||
}
|
||||
.unsloth-login-card button:hover { background: hsl(160, 55%, 36%); }
|
||||
.unsloth-login-card .message { margin-top: 16px; font-size: 13px; }
|
||||
.unsloth-login-card .message.error { color: hsl(0, 75%, 68%); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block site %}
|
||||
{# A different Unsloth Studio sloth sticker each visit (matches Studio's login).
|
||||
The PNGs are copied into static/sloth/NN.png by the image build; if one is
|
||||
missing the onerror handler falls back to the Unsloth logo so the page never
|
||||
shows a broken image. #}
|
||||
{% set sloths = [
|
||||
"01.png", "02.png", "03.png", "04.png", "05.png", "06.png", "07.png",
|
||||
"08.png", "09.png", "10.png", "11.png", "12.png", "13.png", "14.png",
|
||||
"15.png", "16.png", "17.png", "18.png", "19.png", "20.png"
|
||||
] %}
|
||||
<div class="unsloth-login-card">
|
||||
<img class="sloth" src='{{ static_url("sloth/" ~ (sloths | random)) }}'
|
||||
onerror="this.onerror=null;this.className='logo';this.src='{{ static_url('logo/logo.png') }}';"
|
||||
alt='Unsloth' />
|
||||
<h1>Unsloth</h1>
|
||||
<p class="sub">Sign in to JupyterLab</p>
|
||||
{% if login_available %}
|
||||
<form action="{{base_url}}login?next={{next}}" method="post">
|
||||
{{ xsrf_form_html() | safe }}
|
||||
<label for="password_input">
|
||||
{% if token_available %}{% trans %}Password or token{% endtrans %}{% else %}{% trans %}Password{% endtrans %}{% endif %}
|
||||
</label>
|
||||
<input type="password" name="password" id="password_input" autofocus>
|
||||
<button type="submit" id="login_submit">{% trans %}Log in{% endtrans %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if message %}
|
||||
{% for key in message %}
|
||||
<div class="message {{key}}">{{ message[key] }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}{% endblock %}
|
||||
BIN
docker/jupyter/logo.png
Normal file
BIN
docker/jupyter/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
41
docker/jupyter/overrides.json
Normal file
41
docker/jupyter/overrides.json
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"@jupyterlab/apputils-extension:themes": {
|
||||
"theme": "Unsloth Dark",
|
||||
"theme-scrollbars": true,
|
||||
"adaptive-theme": true,
|
||||
"preferred-light-theme": "JupyterLab Light",
|
||||
"preferred-dark-theme": "Unsloth Dark"
|
||||
},
|
||||
"@jupyterlab/notebook-extension:tracker": {
|
||||
"windowingMode": "none",
|
||||
"scrollPastEnd": true,
|
||||
"codeCellConfig": {
|
||||
"autoClosingBrackets": true
|
||||
}
|
||||
},
|
||||
"@jupyterlab/cell-toolbar-extension:plugin": {
|
||||
"toolbar": [
|
||||
{
|
||||
"name": "run-cell-no-advance",
|
||||
"command": "notebook:run-cell",
|
||||
"icon": "ui-components:run",
|
||||
"rank": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"@jupyterlab/notebook-extension:panel": {
|
||||
"toolbar": [
|
||||
{
|
||||
"name": "restart-and-run",
|
||||
"command": "notebook:restart-run-all",
|
||||
"label": "Restart & Run All",
|
||||
"rank": 33
|
||||
}
|
||||
]
|
||||
},
|
||||
"@jupyterlab/apputils-extension:notification": {
|
||||
"fetchNews": "false",
|
||||
"checkForUpdates": false,
|
||||
"doNotDisturbMode": true
|
||||
}
|
||||
}
|
||||
7
docker/jupyter/unsloth_labext/.gitignore
vendored
Normal file
7
docker/jupyter/unsloth_labext/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
lib/
|
||||
*.tsbuildinfo
|
||||
unsloth-jupyterlab/
|
||||
.yarn/
|
||||
.pnp.*
|
||||
yarn.lock
|
||||
1
docker/jupyter/unsloth_labext/.yarnrc.yml
Normal file
1
docker/jupyter/unsloth_labext/.yarnrc.yml
Normal file
|
|
@ -0,0 +1 @@
|
|||
nodeLinker: node-modules
|
||||
52
docker/jupyter/unsloth_labext/package.json
Normal file
52
docker/jupyter/unsloth_labext/package.json
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"name": "unsloth-jupyterlab",
|
||||
"version": "0.1.0",
|
||||
"description": "Unsloth Dark (Monokai) theme + Colab-style cell navigation for JupyterLab.",
|
||||
"keywords": [
|
||||
"jupyter",
|
||||
"jupyterlab",
|
||||
"jupyterlab-extension",
|
||||
"theme"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"author": "Unsloth AI",
|
||||
"private": true,
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"style": "style/index.css",
|
||||
"files": [
|
||||
"lib/**/*.{d.ts,js,js.map}",
|
||||
"style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}",
|
||||
"schema/*.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "jlpm build:lib && jlpm build:labextension:dev",
|
||||
"build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension",
|
||||
"build:lib": "tsc --sourceMap",
|
||||
"build:lib:prod": "tsc",
|
||||
"build:labextension": "jupyter labextension build .",
|
||||
"build:labextension:dev": "jupyter labextension build --development True .",
|
||||
"clean": "rimraf lib tsconfig.tsbuildinfo unsloth-jupyterlab/labextension"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@jupyterlab/application": "^4.5.0",
|
||||
"@jupyterlab/apputils": "^4.5.0",
|
||||
"@jupyterlab/cells": "^4.5.0",
|
||||
"@jupyterlab/codemirror": "^4.5.0",
|
||||
"@jupyterlab/notebook": "^4.5.0",
|
||||
"@jupyterlab/theme-dark-extension": "^4.5.0",
|
||||
"@lumino/widgets": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jupyterlab/builder": "^4.5.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"typescript": "~5.5.0"
|
||||
},
|
||||
"jupyterlab": {
|
||||
"extension": true,
|
||||
"themePath": "style/index.css",
|
||||
"outputDir": "unsloth-jupyterlab/labextension"
|
||||
}
|
||||
}
|
||||
100
docker/jupyter/unsloth_labext/src/cellNav.ts
Normal file
100
docker/jupyter/unsloth_labext/src/cellNav.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import {
|
||||
JupyterFrontEnd,
|
||||
JupyterFrontEndPlugin
|
||||
} from '@jupyterlab/application';
|
||||
import { INotebookTracker } from '@jupyterlab/notebook';
|
||||
|
||||
/**
|
||||
* Colab-style cell navigation that works in BOTH command and edit mode.
|
||||
*
|
||||
* Pressing ArrowDown on the last line of a cell (edit mode) or while a cell is
|
||||
* selected (command mode) moves to the next cell and aligns its TOP to the
|
||||
* viewport; ArrowUp is the mirror. JupyterLab's built-in selection scroll uses
|
||||
* `scrollIntoViewIfNeeded`, which CENTERS any cell taller than the viewport --
|
||||
* so moving onto a cell with a long output (e.g. `trainer.train()`) drops the
|
||||
* view in the middle of the output instead of at the cell top.
|
||||
*
|
||||
* Settings cannot fix this: since JupyterLab 4.1 the editor handles keydown in
|
||||
* the bubbling phase, and the command-mode arrows are owned by Lumino. So we
|
||||
* listen in the CAPTURE phase (before CodeMirror or Lumino see the key), decide
|
||||
* whether we are at a cell boundary, and when we are we move the active cell and
|
||||
* scroll its top into view ourselves.
|
||||
*/
|
||||
const cellNavPlugin: JupyterFrontEndPlugin<void> = {
|
||||
id: 'unsloth-jupyterlab:cell-nav',
|
||||
description:
|
||||
'ArrowDown/ArrowUp move to the TOP of the next/previous cell (command + edit mode).',
|
||||
autoStart: true,
|
||||
requires: [INotebookTracker],
|
||||
activate: (app: JupyterFrontEnd, tracker: INotebookTracker): void => {
|
||||
const handler = (event: KeyboardEvent): void => {
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
|
||||
return;
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
const panel = tracker.currentWidget;
|
||||
if (!panel || !panel.isVisible) {
|
||||
return;
|
||||
}
|
||||
if (!panel.node.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
const notebook = panel.content;
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
||||
const editing = notebook.mode === 'edit';
|
||||
if (editing) {
|
||||
const editor = notebook.activeCell?.editor;
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const line = editor.getCursorPosition().line;
|
||||
// Only take over at the cell boundary; otherwise let CodeMirror move the
|
||||
// cursor within the editor as usual (do not preventDefault/stop).
|
||||
if (direction === 1 && line !== editor.lineCount - 1) {
|
||||
return;
|
||||
}
|
||||
if (direction === -1 && line !== 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const target = notebook.activeCellIndex + direction;
|
||||
if (target < 0 || target >= notebook.widgets.length) {
|
||||
return;
|
||||
}
|
||||
// We own this key now: stop CodeMirror (edit mode) and the Lumino command
|
||||
// system (command mode) from also handling it, which would re-trigger the
|
||||
// centering scroll we are trying to replace.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
notebook.activeCellIndex = target;
|
||||
const cell = notebook.activeCell;
|
||||
const targetEditor = cell?.editor;
|
||||
if (editing && cell && targetEditor) {
|
||||
notebook.mode = 'edit';
|
||||
const lastLine = Math.max(0, targetEditor.lineCount - 1);
|
||||
targetEditor.setCursorPosition({
|
||||
line: direction === 1 ? 0 : lastLine,
|
||||
column: 0
|
||||
});
|
||||
}
|
||||
if (cell) {
|
||||
const node = cell.node;
|
||||
// Defer so this runs AFTER JupyterLab's own ensureFocus/centering scroll
|
||||
// and wins the last write. block:'start' puts the cell input at the top.
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
node.scrollIntoView({ block: 'start' });
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
// Capture phase: decide before CodeMirror / Lumino consume the arrow keys.
|
||||
document.addEventListener('keydown', handler, true);
|
||||
}
|
||||
};
|
||||
|
||||
export default cellNavPlugin;
|
||||
158
docker/jupyter/unsloth_labext/src/colabTitle.ts
Normal file
158
docker/jupyter/unsloth_labext/src/colabTitle.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import {
|
||||
JupyterFrontEnd,
|
||||
JupyterFrontEndPlugin
|
||||
} from '@jupyterlab/application';
|
||||
import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
|
||||
import { Cell } from '@jupyterlab/cells';
|
||||
|
||||
/**
|
||||
* Colab "#@title" form cells. In Colab a code cell whose first line is
|
||||
* `#@title Some Title` renders as a titled, collapsed form: the title shows as a
|
||||
* clickable header, the code is hidden by default ("Show code"), and the output
|
||||
* stays visible. JupyterLab has no equivalent, so this plugin reproduces it.
|
||||
*
|
||||
* For each code cell whose first line matches `#@title <text>` we inject a small
|
||||
* clickable title bar at the top of the cell and hide the cell input by default
|
||||
* via a CSS class on the cell node (we toggle visibility with CSS rather than
|
||||
* the model's source_hidden so we never mutate/persist notebook metadata and the
|
||||
* output area is untouched). Clicking the bar shows/hides the code. Windowing is
|
||||
* disabled image-wide (overrides.json), so cell nodes are stable and the
|
||||
* injected bar persists.
|
||||
*/
|
||||
|
||||
const TITLE_RE = /^\s*#\s*@title\b[ \t]*(.*)$/;
|
||||
const STYLE_ID = 'unsloth-colab-title-style';
|
||||
|
||||
function injectStyle(): void {
|
||||
if (document.getElementById(STYLE_ID)) {
|
||||
return;
|
||||
}
|
||||
const style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
.unsloth-title-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
/* Indent past the cell collapser + prompt gutter so the title aligns with the
|
||||
cell's input/output content column instead of the far-left edge. */
|
||||
margin: 2px 0 2px var(--jp-cell-prompt-width, 64px);
|
||||
user-select: none;
|
||||
border-radius: 4px;
|
||||
/* Heading-2-sized so a #@title form reads like a section heading (matches the
|
||||
rendered-markdown h2 scale, --jp-content-font-size4); the caret inherits
|
||||
this size so it grows too. */
|
||||
font-size: var(--jp-content-font-size4, 1.728em);
|
||||
color: var(--jp-content-font-color1, inherit);
|
||||
}
|
||||
.unsloth-title-bar:hover {
|
||||
background: var(--jp-layout-color2, rgba(128, 128, 128, 0.12));
|
||||
}
|
||||
.unsloth-title-caret {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
line-height: 1;
|
||||
opacity: 0.8;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.unsloth-title-bar.unsloth-collapsed .unsloth-title-caret {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.unsloth-title-text {
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.jp-Cell.unsloth-code-collapsed > .jp-Cell-inputWrapper {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function firstLineOf(cell: Cell): string {
|
||||
try {
|
||||
const raw = cell.model.toJSON().source as string | string[];
|
||||
const text = Array.isArray(raw) ? raw.join('') : String(raw || '');
|
||||
return text.split('\n', 1)[0] || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function applyTitle(cell: Cell): void {
|
||||
let node: HTMLElement;
|
||||
try {
|
||||
node = cell.node;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (cell.model?.type !== 'code') {
|
||||
return;
|
||||
}
|
||||
const match = TITLE_RE.exec(firstLineOf(cell));
|
||||
let bar = node.querySelector(':scope > .unsloth-title-bar') as HTMLElement | null;
|
||||
if (!match) {
|
||||
if (bar) {
|
||||
bar.remove();
|
||||
}
|
||||
node.classList.remove('unsloth-titled', 'unsloth-code-collapsed');
|
||||
return;
|
||||
}
|
||||
// Drop trailing Colab form annotations, e.g. `{ display-mode: "form" }`.
|
||||
const title =
|
||||
(match[1] || '').replace(/\s*\{[^}]*\}\s*$/, '').trim() || 'Title';
|
||||
if (!bar) {
|
||||
const barEl = document.createElement('div');
|
||||
barEl.className = 'unsloth-title-bar unsloth-collapsed';
|
||||
const caret = document.createElement('span');
|
||||
caret.className = 'unsloth-title-caret';
|
||||
caret.textContent = '▾'; // down-pointing triangle
|
||||
const text = document.createElement('span');
|
||||
text.className = 'unsloth-title-text';
|
||||
barEl.appendChild(caret);
|
||||
barEl.appendChild(text);
|
||||
barEl.addEventListener('click', () => {
|
||||
const collapsed = node.classList.toggle('unsloth-code-collapsed');
|
||||
barEl.classList.toggle('unsloth-collapsed', collapsed);
|
||||
});
|
||||
node.insertBefore(barEl, node.firstChild);
|
||||
// Collapsed by default the first time we decorate this cell (Colab default).
|
||||
node.classList.add('unsloth-code-collapsed');
|
||||
bar = barEl;
|
||||
}
|
||||
const label = bar.querySelector('.unsloth-title-text') as HTMLElement | null;
|
||||
if (label) {
|
||||
label.textContent = title;
|
||||
}
|
||||
node.classList.add('unsloth-titled');
|
||||
}
|
||||
|
||||
const colabTitlePlugin: JupyterFrontEndPlugin<void> = {
|
||||
id: 'unsloth-jupyterlab:colab-title',
|
||||
description: 'Render Colab #@title code cells as collapsed, titled forms.',
|
||||
autoStart: true,
|
||||
requires: [INotebookTracker],
|
||||
activate: (app: JupyterFrontEnd, tracker: INotebookTracker): void => {
|
||||
injectStyle();
|
||||
const decorate = (panel: NotebookPanel): void => {
|
||||
const scan = (): void => {
|
||||
panel.content.widgets.forEach(applyTitle);
|
||||
};
|
||||
panel.revealed.then(scan).catch(() => undefined);
|
||||
// Re-scan when cells are added/removed/moved or the user switches cells
|
||||
// (covers editing a #@title line). applyTitle never re-collapses a cell
|
||||
// that already has a bar, so manual expansions are preserved.
|
||||
const model = panel.content.model;
|
||||
if (model) {
|
||||
model.cells.changed.connect(() => window.setTimeout(scan, 0));
|
||||
}
|
||||
panel.content.activeCellChanged.connect(() => window.setTimeout(scan, 0));
|
||||
};
|
||||
tracker.widgetAdded.connect((_, panel) => decorate(panel));
|
||||
tracker.forEach(decorate);
|
||||
}
|
||||
};
|
||||
|
||||
export default colabTitlePlugin;
|
||||
74
docker/jupyter/unsloth_labext/src/index.ts
Normal file
74
docker/jupyter/unsloth_labext/src/index.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import {
|
||||
ILabShell,
|
||||
JupyterFrontEnd,
|
||||
JupyterFrontEndPlugin
|
||||
} from '@jupyterlab/application';
|
||||
import { IThemeManager } from '@jupyterlab/apputils';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
import { UNSLOTH_LOGO_DATA_URI } from './logo';
|
||||
import cellNavPlugin from './cellNav';
|
||||
import colabTitlePlugin from './colabTitle';
|
||||
import outputSelectPlugin from './outputSelect';
|
||||
import uiChromePlugin from './uiChrome';
|
||||
|
||||
/**
|
||||
* The "Unsloth Dark" theme: JupyterLab Dark repainted with the Sublime/Colab
|
||||
* Monokai palette (see style/variables.css). Registered as a named theme so it
|
||||
* appears in Settings > Theme and works with the adaptive (system) light/dark
|
||||
* switch configured in overrides.json.
|
||||
*/
|
||||
const themePlugin: JupyterFrontEndPlugin<void> = {
|
||||
id: 'unsloth-jupyterlab:theme',
|
||||
description: 'Unsloth Dark (Monokai) theme.',
|
||||
autoStart: true,
|
||||
requires: [IThemeManager],
|
||||
activate: (app: JupyterFrontEnd, manager: IThemeManager): void => {
|
||||
const style = 'unsloth-jupyterlab/index.css';
|
||||
manager.register({
|
||||
name: 'Unsloth Dark',
|
||||
isLight: false,
|
||||
themeScrollbars: true,
|
||||
load: () => manager.loadCSS(style),
|
||||
unload: () => Promise.resolve(undefined)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Replace the top-left Jupyter logo with the Unsloth logo. The stock
|
||||
* `@jupyterlab/application-extension:logo` plugin is disabled + locked at image
|
||||
* build time (jupyter labextension disable/lock), so this is the only logo
|
||||
* widget added to the top bar. We render an <img> with inline styles rather than
|
||||
* a LabIcon/CSS so the branding shows identically regardless of the active theme
|
||||
* (the theme CSS is only loaded while Unsloth Dark is selected).
|
||||
*/
|
||||
const logoPlugin: JupyterFrontEndPlugin<void> = {
|
||||
id: 'unsloth-jupyterlab:logo',
|
||||
description: 'Replace the top-left Jupyter logo with the Unsloth logo.',
|
||||
autoStart: true,
|
||||
requires: [ILabShell],
|
||||
activate: (app: JupyterFrontEnd, shell: ILabShell): void => {
|
||||
const logo = new Widget();
|
||||
const img = document.createElement('img');
|
||||
img.src = UNSLOTH_LOGO_DATA_URI;
|
||||
img.alt = 'Unsloth';
|
||||
img.style.height = '24px';
|
||||
img.style.width = 'auto';
|
||||
img.style.margin = '1px 6px 1px 8px';
|
||||
img.style.display = 'block';
|
||||
logo.node.appendChild(img);
|
||||
logo.node.style.display = 'flex';
|
||||
logo.node.style.alignItems = 'center';
|
||||
logo.id = 'jp-MainLogo';
|
||||
shell.add(logo, 'top', { rank: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
export default [
|
||||
themePlugin,
|
||||
cellNavPlugin,
|
||||
logoPlugin,
|
||||
colabTitlePlugin,
|
||||
outputSelectPlugin,
|
||||
uiChromePlugin
|
||||
];
|
||||
5
docker/jupyter/unsloth_labext/src/logo.ts
Normal file
5
docker/jupyter/unsloth_labext/src/logo.ts
Normal file
File diff suppressed because one or more lines are too long
126
docker/jupyter/unsloth_labext/src/outputSelect.ts
Normal file
126
docker/jupyter/unsloth_labext/src/outputSelect.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import {
|
||||
JupyterFrontEnd,
|
||||
JupyterFrontEndPlugin
|
||||
} from '@jupyterlab/application';
|
||||
|
||||
/**
|
||||
* Colab-style Ctrl/Cmd+A inside a cell output.
|
||||
*
|
||||
* In JupyterLab, clicking a cell's output leaves the notebook in command mode
|
||||
* (an output area is not an editor), so Ctrl/Cmd+A fires `notebook:select-all`
|
||||
* which selects EVERY cell in the notebook. On a large notebook that is both
|
||||
* surprising and laggy. Colab instead selects only the text of the output you
|
||||
* clicked. This plugin reproduces that: when the keystroke originates from
|
||||
* within an output area we select just that output's text and stop the event so
|
||||
* the notebook-wide select-all command never runs.
|
||||
*
|
||||
* We listen in the CAPTURE phase (before Lumino's command keybindings) and only
|
||||
* act when:
|
||||
* - the chord is exactly Ctrl/Cmd+A (no Alt; Shift ignored), and
|
||||
* - focus is NOT in a text editor / input / contenteditable (so editing a
|
||||
* code cell with Ctrl+A still selects within that editor), and
|
||||
* - the current selection anchor or the last pointer-down landed inside an
|
||||
* output area.
|
||||
* In every other case we do nothing and JupyterLab keeps its default behaviour.
|
||||
*/
|
||||
|
||||
// Output containers, widest first. `.jp-OutputArea-output` is a single output;
|
||||
// `.jp-Cell-outputWrapper` is the whole output column of one cell (covers the
|
||||
// case where a click lands on padding between outputs).
|
||||
const OUTPUT_SELECTORS = ['.jp-OutputArea-output', '.jp-Cell-outputWrapper'];
|
||||
|
||||
function closestOutput(node: Node | null): HTMLElement | null {
|
||||
const el =
|
||||
node == null
|
||||
? null
|
||||
: node.nodeType === Node.ELEMENT_NODE
|
||||
? (node as HTMLElement)
|
||||
: node.parentElement;
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
for (const sel of OUTPUT_SELECTORS) {
|
||||
const hit = el.closest(sel) as HTMLElement | null;
|
||||
if (hit) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inEditableContext(): boolean {
|
||||
const ae = document.activeElement as HTMLElement | null;
|
||||
if (!ae) {
|
||||
return false;
|
||||
}
|
||||
if (ae.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
const tag = ae.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') {
|
||||
return true;
|
||||
}
|
||||
// CodeMirror 6 editor (cell input in edit mode).
|
||||
return !!ae.closest('.cm-editor');
|
||||
}
|
||||
|
||||
const outputSelectPlugin: JupyterFrontEndPlugin<void> = {
|
||||
id: 'unsloth-jupyterlab:output-select-all',
|
||||
description:
|
||||
'Ctrl/Cmd+A inside a cell output selects only that output, not every cell.',
|
||||
autoStart: true,
|
||||
activate: (_app: JupyterFrontEnd): void => {
|
||||
// Remember where the last pointer-down landed: a click on an image / widget
|
||||
// output may not leave a text selection inside it, so the selection anchor
|
||||
// alone is not enough to know which output the user means.
|
||||
let lastPointerOutput: HTMLElement | null = null;
|
||||
document.addEventListener(
|
||||
'pointerdown',
|
||||
(event: PointerEvent): void => {
|
||||
lastPointerOutput = closestOutput(event.target as Node | null);
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
const handler = (event: KeyboardEvent): void => {
|
||||
if (event.key !== 'a' && event.key !== 'A') {
|
||||
return;
|
||||
}
|
||||
if (!(event.ctrlKey || event.metaKey) || event.altKey) {
|
||||
return;
|
||||
}
|
||||
if (inEditableContext()) {
|
||||
return;
|
||||
}
|
||||
// Prefer the output holding the current selection; fall back to the last
|
||||
// place the user clicked.
|
||||
const selection = window.getSelection();
|
||||
let output = closestOutput(selection?.anchorNode ?? null);
|
||||
if (!output) {
|
||||
output = lastPointerOutput;
|
||||
}
|
||||
if (!output) {
|
||||
return;
|
||||
}
|
||||
// We own this key: prevent `notebook:select-all` (Lumino, command mode)
|
||||
// from also running and selecting the whole notebook.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(output);
|
||||
const sel = window.getSelection();
|
||||
if (sel) {
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
};
|
||||
// Capture phase: decide before Lumino's keybindings consume Ctrl/Cmd+A.
|
||||
document.addEventListener('keydown', handler, true);
|
||||
}
|
||||
};
|
||||
|
||||
export default outputSelectPlugin;
|
||||
57
docker/jupyter/unsloth_labext/src/uiChrome.ts
Normal file
57
docker/jupyter/unsloth_labext/src/uiChrome.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import {
|
||||
ILabShell,
|
||||
JupyterFrontEnd,
|
||||
JupyterFrontEndPlugin
|
||||
} from '@jupyterlab/application';
|
||||
|
||||
/**
|
||||
* Colab-like chrome tweaks applied image-wide.
|
||||
*
|
||||
* Hide the right activity bar (the vertical strip that carries the Property
|
||||
* Inspector / Debugger tabs) by default. JupyterLab has no settings key to hide
|
||||
* a side activity bar outright -- `@jupyterlab/application-extension:shell` only
|
||||
* exposes `activityBarPosition` (move it) and `layout` (reposition widgets) --
|
||||
* so we hide the strip with always-on CSS (independent of the active theme) and
|
||||
* collapse the right panel once on startup. Panels can still be reopened from
|
||||
* the View menu / command palette; nothing is removed, only hidden by default.
|
||||
*/
|
||||
|
||||
const STYLE_ID = 'unsloth-ui-chrome-style';
|
||||
|
||||
function injectStyle(): void {
|
||||
if (document.getElementById(STYLE_ID)) {
|
||||
return;
|
||||
}
|
||||
const style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
/* Hide the right-hand activity bar strip (Property Inspector / Debugger tabs). */
|
||||
.jp-SideBar.jp-mod-right {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
const uiChromePlugin: JupyterFrontEndPlugin<void> = {
|
||||
id: 'unsloth-jupyterlab:ui-chrome',
|
||||
description: 'Hide the right activity bar by default (Colab-like chrome).',
|
||||
autoStart: true,
|
||||
requires: [ILabShell],
|
||||
activate: (app: JupyterFrontEnd, shell: ILabShell): void => {
|
||||
injectStyle();
|
||||
// Collapse the right area once the layout is restored so a previously
|
||||
// expanded right panel does not linger on first paint.
|
||||
app.restored
|
||||
.then(() => {
|
||||
try {
|
||||
shell.collapseRight();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
export default uiChromePlugin;
|
||||
6
docker/jupyter/unsloth_labext/style/index.css
Normal file
6
docker/jupyter/unsloth_labext/style/index.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/* "Unsloth Dark" theme entry point.
|
||||
* Start from the built-in JupyterLab Dark theme (theme.css pulls in its full
|
||||
* variable set + base rules), then override the palette with the Sublime/Colab
|
||||
* Monokai colors in variables.css. */
|
||||
@import url('@jupyterlab/theme-dark-extension/style/theme.css');
|
||||
@import url('./variables.css');
|
||||
97
docker/jupyter/unsloth_labext/style/variables.css
Normal file
97
docker/jupyter/unsloth_labext/style/variables.css
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/* Unsloth Dark = Sublime/Colab "Monokai" palette, overriding JupyterLab Dark.
|
||||
* Applied on :root because the theme manager only loads this file while the
|
||||
* "Unsloth Dark" theme is active, so it never affects the light theme.
|
||||
*
|
||||
* Exact HSL from Sublime "Monokai":
|
||||
* bg hsl(70,8%,15%) fg hsl(60,30%,96%) selection hsla(55,8%,31%,.7)
|
||||
* comment hsl(50,11%,41%) string hsl(54,70%,68%) number hsl(261,100%,75%)
|
||||
* keyword hsl(338,95%,56%) function hsl(80,76%,53%) builtin hsl(190,81%,67%)
|
||||
* param hsl(32,98%,56%) error hsl(0,93%,59%)
|
||||
*/
|
||||
:root {
|
||||
/* surfaces */
|
||||
--jp-layout-color0: hsl(70, 8%, 12%);
|
||||
--jp-layout-color1: hsl(70, 8%, 15%);
|
||||
--jp-layout-color2: hsl(70, 8%, 10%);
|
||||
--jp-layout-color3: hsl(70, 8%, 8%);
|
||||
--jp-layout-color4: hsl(70, 8%, 6%);
|
||||
--jp-toolbar-background: hsl(70, 8%, 13%);
|
||||
--jp-cell-editor-background: hsl(70, 8%, 15%);
|
||||
--jp-cell-editor-active-background: hsl(70, 8%, 15%);
|
||||
--jp-cell-editor-border-color: hsl(70, 8%, 22%);
|
||||
--jp-rendermime-host-background: hsl(70, 8%, 15%);
|
||||
--jp-rendermime-error-background: hsla(338, 50%, 56%, 0.15);
|
||||
--jp-cell-prompt-not-active-font-color: hsl(60, 8%, 55%);
|
||||
--jp-notebook-multiselected-color: hsla(80, 40%, 40%, 0.18);
|
||||
|
||||
/* inverse surfaces */
|
||||
--jp-inverse-layout-color0: hsl(60, 30%, 98%);
|
||||
--jp-inverse-layout-color1: hsl(60, 30%, 96%);
|
||||
--jp-inverse-layout-color2: hsl(60, 10%, 72%);
|
||||
--jp-inverse-layout-color3: hsl(60, 8%, 55%);
|
||||
|
||||
/* text */
|
||||
--jp-ui-font-color0: hsl(60, 30%, 98%);
|
||||
--jp-ui-font-color1: hsl(60, 18%, 90%);
|
||||
--jp-ui-font-color2: hsl(60, 8%, 66%);
|
||||
--jp-ui-font-color3: hsl(60, 6%, 46%);
|
||||
--jp-content-font-color0: hsl(60, 30%, 98%);
|
||||
--jp-content-font-color1: hsl(60, 30%, 96%);
|
||||
--jp-content-font-color2: hsl(60, 12%, 72%);
|
||||
--jp-content-font-color3: hsl(60, 8%, 52%);
|
||||
|
||||
/* borders */
|
||||
--jp-border-color0: hsl(70, 8%, 26%);
|
||||
--jp-border-color1: hsl(70, 8%, 22%);
|
||||
--jp-border-color2: hsl(70, 8%, 18%);
|
||||
--jp-border-color3: hsl(70, 8%, 14%);
|
||||
|
||||
/* accent / links / brand */
|
||||
--jp-content-link-color: hsl(190, 81%, 67%);
|
||||
--jp-brand-color0: hsl(190, 81%, 72%);
|
||||
--jp-brand-color1: hsl(190, 70%, 58%);
|
||||
--jp-brand-color2: hsl(190, 60%, 46%);
|
||||
--jp-brand-color3: hsl(190, 55%, 36%);
|
||||
--jp-accent-color1: hsl(80, 76%, 48%);
|
||||
--jp-warn-color1: hsl(32, 98%, 56%);
|
||||
--jp-error-color1: hsl(0, 93%, 59%);
|
||||
--jp-success-color1: hsl(80, 76%, 45%);
|
||||
|
||||
/* selection / cursor */
|
||||
--jp-editor-selected-background: hsla(55, 8%, 31%, 0.55);
|
||||
--jp-editor-selected-focused-background: hsla(55, 8%, 31%, 0.75);
|
||||
--jp-editor-cursor-color: hsl(60, 36%, 96%);
|
||||
|
||||
/* CodeMirror 6 syntax tokens (Monokai) */
|
||||
--jp-mirror-editor-keyword-color: hsl(338, 95%, 56%);
|
||||
--jp-mirror-editor-atom-color: hsl(261, 100%, 75%);
|
||||
--jp-mirror-editor-number-color: hsl(261, 100%, 75%);
|
||||
--jp-mirror-editor-def-color: hsl(80, 76%, 53%);
|
||||
--jp-mirror-editor-variable-color: hsl(60, 30%, 96%);
|
||||
--jp-mirror-editor-variable-2-color: hsl(32, 98%, 56%);
|
||||
--jp-mirror-editor-variable-3-color: hsl(190, 81%, 67%);
|
||||
--jp-mirror-editor-punctuation-color: hsl(60, 18%, 85%);
|
||||
--jp-mirror-editor-property-color: hsl(80, 76%, 53%);
|
||||
--jp-mirror-editor-operator-color: hsl(338, 95%, 56%);
|
||||
--jp-mirror-editor-comment-color: hsl(50, 11%, 41%);
|
||||
--jp-mirror-editor-string-color: hsl(54, 70%, 68%);
|
||||
--jp-mirror-editor-string-2-color: hsl(54, 70%, 68%);
|
||||
--jp-mirror-editor-meta-color: hsl(190, 81%, 67%);
|
||||
--jp-mirror-editor-builtin-color: hsl(190, 81%, 67%);
|
||||
--jp-mirror-editor-tag-color: hsl(338, 95%, 56%);
|
||||
--jp-mirror-editor-attribute-color: hsl(80, 76%, 53%);
|
||||
--jp-mirror-editor-header-color: hsl(338, 95%, 56%);
|
||||
--jp-mirror-editor-quote-color: hsl(80, 76%, 53%);
|
||||
--jp-mirror-editor-link-color: hsl(190, 81%, 67%);
|
||||
--jp-mirror-editor-error-color: hsl(0, 93%, 59%);
|
||||
--jp-mirror-editor-activeline-background: hsl(55, 11%, 22%);
|
||||
--jp-mirror-editor-matchingbracket-color: hsl(54, 70%, 68%);
|
||||
}
|
||||
|
||||
/* Active line tint inside the code editor (Monokai line_highlight). */
|
||||
.cm-editor .cm-activeLine {
|
||||
background-color: hsla(55, 11%, 30%, 0.35);
|
||||
}
|
||||
.cm-editor .cm-activeLineGutter {
|
||||
background-color: hsla(55, 11%, 30%, 0.35);
|
||||
}
|
||||
26
docker/jupyter/unsloth_labext/tsconfig.json
Normal file
26
docker/jupyter/unsloth_labext/tsconfig.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"incremental": true,
|
||||
"jsx": "react",
|
||||
"lib": ["DOM", "ES2018", "ES2020.Promise"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"noEmitOnError": true,
|
||||
"noImplicitAny": true,
|
||||
"noUnusedLocals": true,
|
||||
"preserveWatchOutput": true,
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"target": "ES2018",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/*"]
|
||||
}
|
||||
|
|
@ -26,8 +26,8 @@
|
|||
# The full image (unsloth/unsloth:latest) starts Studio (8000) + JupyterLab
|
||||
# (8888) by default; publish the ports when you want them:
|
||||
# UNSLOTH_PORTS="-p 8000:8000 -p 8888:8888" bash docker/run.sh
|
||||
# JupyterLab on the lean base image (unsloth/unsloth:base):
|
||||
# UNSLOTH_PORTS="-p 8888:8888" UNSLOTH_IMAGE=unsloth/unsloth:base \
|
||||
# JupyterLab on the lean core image (unsloth/unsloth:core):
|
||||
# UNSLOTH_PORTS="-p 8888:8888" UNSLOTH_IMAGE=unsloth/unsloth:core \
|
||||
# bash docker/run.sh jupyter lab --ip 0.0.0.0 --port 8888 --allow-root
|
||||
# CPU-only hosts (Docker Desktop on macOS, Windows without WSL2 GPU, plain
|
||||
# CPU Linux): no --gpus and set UNSLOTH_ALLOW_CPU=1. Training is unavailable
|
||||
|
|
|
|||
|
|
@ -61,6 +61,14 @@ PY
|
|||
c.ServerApp.ip = "0.0.0.0"
|
||||
c.ServerApp.open_browser = False
|
||||
c.ServerApp.root_dir = "/workspace"
|
||||
# Open straight into the categorized notebook view (built by unsloth-sync-notebooks).
|
||||
# default_url must be set on BOTH ServerApp and LabApp -- the lab extension app
|
||||
# otherwise overrides ServerApp's value back to /lab. preferred_dir makes the
|
||||
# file browser default to that folder. Use a literal space (JupyterLab
|
||||
# URL-encodes it to %20 in the redirect itself).
|
||||
c.ServerApp.default_url = "/lab/tree/Unsloth Notebooks"
|
||||
c.LabApp.default_url = "/lab/tree/Unsloth Notebooks"
|
||||
c.ServerApp.preferred_dir = "/workspace/Unsloth Notebooks"
|
||||
c.PasswordIdentityProvider.hashed_password = "${HASH}"
|
||||
EOF
|
||||
fi
|
||||
|
|
|
|||
65
docker/unsloth_colab_compat.py
Normal file
65
docker/unsloth_colab_compat.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""Colab cell-magic compatibility for the Unsloth Docker notebooks.
|
||||
|
||||
Colab cells often look like:
|
||||
|
||||
#@title Colab Extra Install { display-mode: "form" }
|
||||
%%capture
|
||||
!pip install ...
|
||||
|
||||
In IPython a cell magic (`%%capture`, `%%bash`, ...) is only recognised when it
|
||||
is the VERY FIRST line of the cell. A leading Colab `#@title`/`#@param` form (or
|
||||
any comment/blank line) pushes the `%%magic` to line 2, so IPython treats it as a
|
||||
line magic and raises `UsageError: Line magic function `%%capture` not found.`
|
||||
and the cell fails.
|
||||
|
||||
Fix: register an `input_transformers_cleanup` (runs before magic detection) that
|
||||
hoists a `%%` cell magic above any leading blank/comment (`#...`, incl. `#@...`)
|
||||
lines, so the magic lands on line 0 and fires normally. The skipped comment lines
|
||||
stay in the cell (still inert), just below the magic -- so `%%capture` now also
|
||||
captures them. Idempotent and fully guarded: any problem returns the input
|
||||
unchanged, so a cell never breaks because of this helper.
|
||||
|
||||
This mirrors unsloth_nb_compat.register_ipython(): it is wired from the baked
|
||||
IPython startup file (docker/unsloth_ipython_startup.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
|
||||
|
||||
def colab_cell_magic_fix(lines):
|
||||
"""Hoist a `%%` cell magic above leading blank/comment lines.
|
||||
|
||||
`lines` is the IPython cell as a list of strings (each ending in '\\n').
|
||||
Returns a (possibly reordered) list of the same lines.
|
||||
"""
|
||||
try:
|
||||
skipped = []
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped == "" or stripped.startswith("#"):
|
||||
skipped.append(line) # blank or comment (incl. #@title)
|
||||
continue
|
||||
# First real line. Only act if it is a cell magic that is not yet on
|
||||
# top (i.e. something was skipped before it).
|
||||
if stripped.startswith("%%") and i > 0:
|
||||
return [line] + skipped + lines[i + 1:]
|
||||
return lines # already on top, or not a magic
|
||||
return lines # all blank/comment -> nothing to do
|
||||
except Exception:
|
||||
return lines
|
||||
|
||||
|
||||
def register_ipython():
|
||||
"""Append the transformer to the running IPython (called from startup)."""
|
||||
try:
|
||||
ip = get_ipython() # noqa: F821 (provided by IPython)
|
||||
except NameError:
|
||||
return
|
||||
if ip is None or getattr(ip, "_unsloth_colab_fix", False):
|
||||
return
|
||||
try:
|
||||
ip.input_transformers_cleanup.append(colab_cell_magic_fix)
|
||||
ip._unsloth_colab_fix = True
|
||||
except Exception as e: # never break a kernel because of the helper
|
||||
print(f"[unsloth-nb] colab-compat hook skipped: {e!r}", file=sys.stderr)
|
||||
|
|
@ -19,3 +19,14 @@ try:
|
|||
except Exception as _e: # never break a kernel because of the helper
|
||||
import sys
|
||||
print(f"[unsloth-nb] startup hook skipped: {_e!r}", file = sys.stderr)
|
||||
|
||||
# Colab cell-magic compatibility (hoist `%%capture` above a leading `#@title`
|
||||
# form so it fires instead of raising UsageError). Independent try/except so a
|
||||
# failure here never disables the transformers-sidecar hook above and vice versa.
|
||||
try:
|
||||
import unsloth_colab_compat
|
||||
|
||||
unsloth_colab_compat.register_ipython()
|
||||
except Exception as _e: # never break a kernel because of the helper
|
||||
import sys
|
||||
print(f"[unsloth-nb] colab-compat hook skipped: {_e!r}", file = sys.stderr)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,15 @@ SIDECAR_ROOT = os.environ.get("UNSLOTH_TF_SIDECAR_ROOT", "/opt/unsloth-venv/tf-s
|
|||
# The pip/uv shim writes the transformers version a notebook asked for here.
|
||||
MARKER = os.environ.get("UNSLOTH_NB_TF_MARKER", "/tmp/unsloth_nb/requested_transformers")
|
||||
|
||||
|
||||
def _logging_enabled() -> bool:
|
||||
"""Sidecar activation is silent by default; users found the per-cell
|
||||
`[unsloth-nb] activated transformers sidecar ...` line noisy. Set
|
||||
UNSLOTH_ENABLE_LOGGING=1 to surface it (and other [unsloth-nb] diagnostics)."""
|
||||
return os.environ.get("UNSLOTH_ENABLE_LOGGING", "").strip().lower() not in (
|
||||
"", "0", "false", "no", "off",
|
||||
)
|
||||
|
||||
# Model-name -> minimum transformers tier, ported from Studio's
|
||||
# transformers_version.py (substring match on the lowered model id). Used as a
|
||||
# fallback when a notebook does not pin transformers but names a new-family model.
|
||||
|
|
@ -117,7 +126,7 @@ def activate(version: str | None, *, quiet: bool = False):
|
|||
if d not in sys.path:
|
||||
sys.path.insert(0, d)
|
||||
os.environ["PYTHONPATH"] = d + os.pathsep + os.environ.get("PYTHONPATH", "")
|
||||
if not quiet:
|
||||
if not quiet and _logging_enabled():
|
||||
print(f"[unsloth-nb] activated transformers sidecar for {version}: {d}")
|
||||
return d
|
||||
|
||||
|
|
|
|||
215
docker/unsloth_nb_strip_colab.py
Normal file
215
docker/unsloth_nb_strip_colab.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
#!/usr/bin/env python3
|
||||
# Remove the Colab-only "how to run" sentence from Unsloth notebooks for Docker.
|
||||
#
|
||||
# Every generated notebook opens with a first markdown cell whose first line is a
|
||||
# Colab instruction, e.g.
|
||||
#
|
||||
# To run this, press "*Runtime*" and press "*Run all*" on a **free** Tesla T4
|
||||
# Google Colab instance!
|
||||
# ... (and the A100 / L4 / "AMD Dev Cloud" variants) ...
|
||||
#
|
||||
# Inside the Docker image there is no "Runtime > Run all" menu and no Colab GPU,
|
||||
# so the sentence is wrong/confusing. This strips ONLY that leading sentence; the
|
||||
# rest of the cell (the Unsloth badge row, the "install on your local device"
|
||||
# guide link, the "You will learn how to do ..." line) is kept untouched.
|
||||
#
|
||||
# This is a Docker-only transform applied at notebook-sync time. It is NOT pushed
|
||||
# upstream: on Colab the sentence is correct, so the public notebooks keep it.
|
||||
#
|
||||
# Two modes:
|
||||
# unsloth_nb_strip_colab.py <a.ipynb> [b.ipynb ...]
|
||||
# strip the listed notebooks in place (idempotent).
|
||||
# unsloth_nb_strip_colab.py --state <STATE> --dest <DEST>
|
||||
# STATE-aware sync migration. STATE is the "<sha256> <relpath>" file that
|
||||
# unsloth_sync_notebooks.sh records for every file it wrote. For each
|
||||
# .ipynb entry that still hashes to its recorded value (i.e. WE own it and
|
||||
# the user has not edited it), strip the intro and update the recorded hash
|
||||
# in place. User-edited notebooks (current hash != recorded) are left
|
||||
# untouched. This is the safe "rewrite, then record" step the sync runs
|
||||
# after every STATE write, so it covers first-boot populate, deleted-file
|
||||
# restore, GitHub refresh, and in-place image upgrades in one pass.
|
||||
#
|
||||
# Safe with refresh decisions: unsloth_nb_content_sig.py already classifies the
|
||||
# intro cell as boilerplate, so the body digest used to detect "only boilerplate
|
||||
# moved upstream" is identical whether or not the sentence is present.
|
||||
#
|
||||
# Exit code is always 0.
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# The stable identifier for the offending line (covers every GPU/Cloud variant).
|
||||
_INTRO_PREFIX = "to run this, press"
|
||||
|
||||
# ipywidgets MIME types. The baked notebooks ship example tqdm/progress-bar
|
||||
# widget outputs (model.safetensors download bars, dataset Map bars, ...) plus a
|
||||
# metadata.widgets state block. JupyterLab's ipywidgets manager cannot always
|
||||
# rebuild the Colab-saved state, so those outputs render as a stuck
|
||||
# "Loading widget..." placeholder. Dropping the widget outputs + orphan state
|
||||
# removes the placeholder; running the cell yourself still creates a fresh,
|
||||
# working widget. Outputs are not part of the refresh signature
|
||||
# (unsloth_nb_content_sig.middle_digest hashes only cell type+source), so this is
|
||||
# safe for edit/refresh detection.
|
||||
_WIDGET_VIEW_MIME = "application/vnd.jupyter.widget-view+json"
|
||||
|
||||
|
||||
def _strip_lines(lines):
|
||||
"""Drop the intro line (and an immediately-following blank). Return new list
|
||||
or None if there was nothing to strip."""
|
||||
for i, line in enumerate(lines):
|
||||
if line.lstrip().lower().startswith(_INTRO_PREFIX):
|
||||
out = lines[:i] + lines[i + 1:]
|
||||
if i < len(out) and out[i].strip() == "":
|
||||
out = out[:i] + out[i + 1:]
|
||||
return out
|
||||
return None
|
||||
|
||||
|
||||
def _strip_intro(nb):
|
||||
"""Strip the Colab intro sentence from cells[0]. Return True if changed."""
|
||||
cells = nb.get("cells")
|
||||
if not isinstance(cells, list) or not cells:
|
||||
return False
|
||||
cell = cells[0]
|
||||
if not isinstance(cell, dict) or cell.get("cell_type") != "markdown":
|
||||
return False
|
||||
src = cell.get("source")
|
||||
if isinstance(src, str):
|
||||
lines = src.splitlines(keepends=True)
|
||||
as_str = True
|
||||
elif isinstance(src, list):
|
||||
lines = list(src)
|
||||
as_str = False
|
||||
else:
|
||||
return False
|
||||
new_lines = _strip_lines(lines)
|
||||
if new_lines is None:
|
||||
return False
|
||||
cell["source"] = "".join(new_lines) if as_str else new_lines
|
||||
return True
|
||||
|
||||
|
||||
def _clean_widgets(nb):
|
||||
"""Drop baked ipywidget outputs + the orphan widget-state metadata that
|
||||
otherwise render as "Loading widget...". Return True if changed."""
|
||||
changed = False
|
||||
cells = nb.get("cells")
|
||||
if isinstance(cells, list):
|
||||
for cell in cells:
|
||||
if not isinstance(cell, dict):
|
||||
continue
|
||||
outs = cell.get("outputs")
|
||||
if not isinstance(outs, list):
|
||||
continue
|
||||
kept = [
|
||||
o for o in outs
|
||||
if not (isinstance(o, dict) and _WIDGET_VIEW_MIME in (o.get("data") or {}))
|
||||
]
|
||||
if len(kept) != len(outs):
|
||||
cell["outputs"] = kept
|
||||
changed = True
|
||||
md = nb.get("metadata")
|
||||
if isinstance(md, dict) and "widgets" in md:
|
||||
del md["widgets"]
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def strip_notebook(path):
|
||||
"""Return True if the notebook was modified and written back."""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
nb = json.load(f)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Apply both transforms; write back if either changed.
|
||||
changed = _strip_intro(nb)
|
||||
changed = _clean_widgets(nb) or changed
|
||||
if not changed:
|
||||
return False
|
||||
|
||||
tmp = path + ".tmp"
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(nb, f, indent=1, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
os.replace(tmp, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _sha256(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def migrate(state_path, dest):
|
||||
"""Strip owned+unedited notebooks listed in STATE and update their hashes."""
|
||||
try:
|
||||
with open(state_path, "r", encoding="utf-8") as f:
|
||||
lines = f.read().splitlines()
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
out = []
|
||||
changed = 0
|
||||
for line in lines:
|
||||
parts = line.split(" ", 1) # "<sha256> <relpath>"
|
||||
if len(parts) != 2:
|
||||
out.append(line)
|
||||
continue
|
||||
rec, rel = parts
|
||||
path = os.path.join(dest, rel)
|
||||
if rel.endswith(".ipynb") and os.path.isfile(path):
|
||||
try:
|
||||
if _sha256(path) == rec: # we own it and it is unedited
|
||||
if strip_notebook(path):
|
||||
rec = _sha256(path)
|
||||
changed += 1
|
||||
except OSError:
|
||||
pass
|
||||
out.append("%s %s" % (rec, rel))
|
||||
|
||||
if changed:
|
||||
tmp = state_path + ".tmp"
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(out) + "\n")
|
||||
os.replace(tmp, state_path)
|
||||
except OSError:
|
||||
pass
|
||||
print(f"[unsloth-nb] cleaned {changed} notebook(s) (Colab intro + widget outputs)")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv):
|
||||
ap = argparse.ArgumentParser(description="Strip the Colab-only intro sentence.")
|
||||
ap.add_argument("--state", help="sync state file (enables migration mode)")
|
||||
ap.add_argument("--dest", help="notebooks dir (with --state)")
|
||||
ap.add_argument("paths", nargs="*", help="notebooks to strip in place")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.state:
|
||||
if not args.dest:
|
||||
ap.error("--state requires --dest")
|
||||
return migrate(args.state, args.dest)
|
||||
|
||||
changed = sum(1 for p in args.paths if strip_notebook(p))
|
||||
if changed:
|
||||
print(f"[unsloth-nb] cleaned {changed} notebook(s) (Colab intro + widget outputs)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
209
docker/unsloth_nb_view.py
Normal file
209
docker/unsloth_nb_view.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
#!/usr/bin/env python3
|
||||
# Build a categorized, Colab-like folder VIEW of the Unsloth notebooks.
|
||||
#
|
||||
# The canonical notebooks live under DEST/nb/<file>.ipynb (a mirror of
|
||||
# unslothai/notebooks, populated + refreshed by unsloth_sync_notebooks.sh). That
|
||||
# flat tree is great for syncing but poor for browsing. This builds a sibling
|
||||
# directory of *relative symlinks* grouped into folders that mirror the README
|
||||
# section headers, e.g.
|
||||
#
|
||||
# <VIEW>/01 Main Notebooks/Llama3_2_(1B_and_3B)_Conversational.ipynb
|
||||
# <VIEW>/02 Gemma 4 Notebooks/...
|
||||
# ...
|
||||
# <VIEW>/99 Other Notebooks/<anything on disk not linked from the README>
|
||||
#
|
||||
# Why symlinks: the real .ipynb files are never moved or renamed, so the sync
|
||||
# state machine (which walks `find -type f`, skipping symlinks) and the
|
||||
# edit/refresh logic are completely unaffected. The VIEW is a sibling of DEST
|
||||
# (outside it), rebuilt from scratch on every boot, and disposable.
|
||||
#
|
||||
# Categorization rules:
|
||||
# * Section = the nearest preceding `### ` header in DEST/README.md. The same
|
||||
# topic header repeats across the Fine-tuning / Kaggle / AMD domains; those
|
||||
# merge into one folder (first appearance fixes the order).
|
||||
# * Folder names are cleaned: dashes and slashes -> spaces, whitespace
|
||||
# collapsed, numbered `NN ` by first appearance so JupyterLab's alpha sort
|
||||
# preserves README order. "Other Notebooks" is always last.
|
||||
# * A notebook linked under several sections lands in its first (README order).
|
||||
# * AMD-*.ipynb are hidden unless --amd (an AMD/HIP GPU was detected).
|
||||
# * Any on-disk nb/*.ipynb not linked from the README goes to "Other Notebooks".
|
||||
#
|
||||
# Usage:
|
||||
# unsloth_nb_view.py <DEST> <VIEW> [--amd] build the symlink view
|
||||
# unsloth_nb_view.py <DEST> --print [--amd] print "section\tfile" rows
|
||||
#
|
||||
# Exit code is 0 on success; on any error it prints a diagnostic to stderr and
|
||||
# exits non-zero so the caller can fall back to the raw tree.
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
# nb/<file>.ipynb in any link form (markdown badge, HTML href, plain link,
|
||||
# Kaggle ?src= form). Filenames use [\w.()-] plus %-escapes (%28/%29 for parens).
|
||||
_NB_RE = re.compile(r"nb/([\w.()%\-]+?\.ipynb)")
|
||||
_OTHER = "Other Notebooks"
|
||||
|
||||
|
||||
def clean_section(title):
|
||||
"""README header text -> a filesystem-friendly folder label."""
|
||||
# Drop a trailing run of '#', surrounding whitespace and any emoji/symbols
|
||||
# that sometimes lead a header; keep ASCII text, digits and a few separators.
|
||||
title = title.strip().strip("#").strip()
|
||||
title = title.replace("-", " ").replace("/", " ")
|
||||
title = re.sub(r"\s+", " ", title).strip()
|
||||
return title
|
||||
|
||||
|
||||
def parse_readme(readme_path):
|
||||
"""Return an ordered list of (section_label, filename) pairs.
|
||||
|
||||
A notebook is intentionally cross-listed under several `###` headers in the
|
||||
README (e.g. ModernBert under both "Embedding" and "BERT"), so that every
|
||||
header becomes a populated folder. We therefore dedup per (section, file) --
|
||||
a file shows up once in EACH section that lists it -- rather than globally.
|
||||
Repeated headers across the Fine-tuning / Kaggle / AMD domains share a label
|
||||
and so merge into one folder downstream.
|
||||
|
||||
filename is the urldecoded basename under nb/ (literal parens, matching disk).
|
||||
"""
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
|
||||
rows = []
|
||||
seen_pairs = set() # (section, filename) already emitted
|
||||
section = None
|
||||
for line in text.splitlines():
|
||||
m = re.match(r"^###\s+(.*)$", line)
|
||||
if m:
|
||||
section = clean_section(m.group(1))
|
||||
continue
|
||||
if section is None:
|
||||
continue
|
||||
for raw in _NB_RE.findall(line):
|
||||
fname = urllib.parse.unquote(raw)
|
||||
key = (section, fname)
|
||||
if key in seen_pairs:
|
||||
continue
|
||||
seen_pairs.add(key)
|
||||
rows.append((section, fname))
|
||||
return rows
|
||||
|
||||
|
||||
def _ordered_sections(rows):
|
||||
"""Section labels in first-appearance order, with Other Notebooks last."""
|
||||
order = []
|
||||
for section, _ in rows:
|
||||
if section not in order:
|
||||
order.append(section)
|
||||
# Force the catch-all to the end even if the README defines it earlier.
|
||||
order = [s for s in order if s != _OTHER] + [_OTHER]
|
||||
return order
|
||||
|
||||
|
||||
def build_view(dest, view, amd=False):
|
||||
nb_dir = os.path.join(dest, "nb")
|
||||
readme = os.path.join(dest, "README.md")
|
||||
if not os.path.isdir(nb_dir):
|
||||
raise SystemExit(f"no nb/ dir under {dest}")
|
||||
|
||||
rows = parse_readme(readme) if os.path.isfile(readme) else []
|
||||
|
||||
def allowed(fname):
|
||||
return amd or not fname.startswith("AMD-")
|
||||
|
||||
# section -> [filenames], preserving README order, AMD-filtered, on-disk only.
|
||||
by_section = {}
|
||||
placed = set()
|
||||
for section, fname in rows:
|
||||
if not allowed(fname):
|
||||
continue
|
||||
if not os.path.isfile(os.path.join(nb_dir, fname)):
|
||||
continue
|
||||
by_section.setdefault(section, []).append(fname)
|
||||
placed.add(fname)
|
||||
|
||||
# Everything on disk that the README never linked -> Other Notebooks.
|
||||
for fname in sorted(os.listdir(nb_dir)):
|
||||
if not fname.endswith(".ipynb"):
|
||||
continue
|
||||
if fname in placed or not allowed(fname):
|
||||
continue
|
||||
by_section.setdefault(_OTHER, []).append(fname)
|
||||
|
||||
order = [s for s in _ordered_sections(rows) if s in by_section]
|
||||
if _OTHER in by_section and _OTHER not in order:
|
||||
order.append(_OTHER)
|
||||
|
||||
# Rebuild VIEW from scratch.
|
||||
_rmtree(view)
|
||||
os.makedirs(view, exist_ok=True)
|
||||
|
||||
n_links = 0
|
||||
for i, section in enumerate(order, start=1):
|
||||
folder = os.path.join(view, f"{i:02d} {section}")
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
for fname in by_section[section]:
|
||||
link = os.path.join(folder, fname)
|
||||
target = os.path.join(nb_dir, fname)
|
||||
rel = os.path.relpath(target, folder) # ../../unsloth-notebooks/nb/<file>
|
||||
try:
|
||||
if os.path.islink(link) or os.path.exists(link):
|
||||
os.remove(link)
|
||||
os.symlink(rel, link)
|
||||
n_links += 1
|
||||
except OSError as e:
|
||||
print(f"[unsloth-nb] view: skip {fname}: {e}", file=sys.stderr)
|
||||
return len(order), n_links
|
||||
|
||||
|
||||
def _rmtree(path):
|
||||
# Remove a previously built VIEW. Only unlinks symlinks + empty dirs we made,
|
||||
# but a full rmtree is fine here because VIEW holds nothing but our symlinks.
|
||||
if not os.path.isdir(path):
|
||||
if os.path.islink(path):
|
||||
os.remove(path)
|
||||
return
|
||||
for root, dirs, files in os.walk(path, topdown=False):
|
||||
for name in files:
|
||||
try:
|
||||
os.remove(os.path.join(root, name))
|
||||
except OSError:
|
||||
pass
|
||||
for name in dirs:
|
||||
p = os.path.join(root, name)
|
||||
try:
|
||||
(os.remove if os.path.islink(p) else os.rmdir)(p)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.rmdir(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main(argv):
|
||||
ap = argparse.ArgumentParser(description="Build the categorized notebook view.")
|
||||
ap.add_argument("dest", help="notebooks dir (contains README.md and nb/)")
|
||||
ap.add_argument("view", nargs="?", help="output view dir (omit with --print)")
|
||||
ap.add_argument("--amd", action="store_true", help="include AMD-* notebooks")
|
||||
ap.add_argument("--print", dest="do_print", action="store_true",
|
||||
help="print section<TAB>file rows instead of building")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.do_print:
|
||||
for section, fname in parse_readme(os.path.join(args.dest, "README.md")):
|
||||
if args.amd or not fname.startswith("AMD-"):
|
||||
print(f"{section}\t{fname}")
|
||||
return 0
|
||||
|
||||
if not args.view:
|
||||
ap.error("view dir is required unless --print is given")
|
||||
n_sections, n_links = build_view(args.dest, args.view, amd=args.amd)
|
||||
print(f"[unsloth-nb] view: {n_links} notebooks in {n_sections} folders -> {args.view}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
|
|
@ -21,6 +21,13 @@
|
|||
# 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}"
|
||||
|
|
@ -46,6 +53,26 @@ if [ -z "$SIG_HELPER" ]; then
|
|||
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.
|
||||
|
|
@ -63,6 +90,53 @@ 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
|
||||
|
|
|
|||
207
tests/validate_studio_features.py
Normal file
207
tests/validate_studio_features.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Cross-platform validation of the Unsloth Docker JupyterLab/notebook features.
|
||||
|
||||
Runs WITHOUT Docker or a GPU, so it can execute on the Linux/macOS/Windows CI
|
||||
lanes. It exercises the actual notebook-helper logic (not just py_compile) and
|
||||
checks the shipped JupyterLab config + labextension source, so a regression in
|
||||
the notebook organisation, Colab compatibility, Colab-intro/widget stripping,
|
||||
sidecar-log gating, the labextension plugins, the JupyterLab defaults, or the
|
||||
login branding fails CI on every device.
|
||||
|
||||
Usage: python tests/validate_studio_features.py
|
||||
Exit 0 = all checks pass; non-zero = at least one failed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DOCKER = os.path.join(ROOT, "docker")
|
||||
JUPYTER = os.path.join(DOCKER, "jupyter")
|
||||
LABEXT = os.path.join(JUPYTER, "unsloth_labext")
|
||||
sys.path.insert(0, DOCKER)
|
||||
|
||||
_failures: list[str] = []
|
||||
|
||||
|
||||
def check(name: str, cond: bool, detail: str = "") -> None:
|
||||
status = "PASS" if cond else "FAIL"
|
||||
print(f" [{status}] {name}" + (f" -- {detail}" if detail and not cond else ""))
|
||||
if not cond:
|
||||
_failures.append(name)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. Colab cell-magic compatibility (#@title then %%capture)
|
||||
# --------------------------------------------------------------------------
|
||||
def test_colab_compat() -> None:
|
||||
print("colab cell-magic compat (unsloth_colab_compat):")
|
||||
m = importlib.import_module("unsloth_colab_compat")
|
||||
out = m.colab_cell_magic_fix(['#@title Setup\n', '%%capture\n', '!pip install x\n'])
|
||||
check("magic hoisted above #@title", out[0] == '%%capture\n' and '#@title Setup\n' in out)
|
||||
# idempotent / already on top
|
||||
same = ['%%capture\n', 'print(1)\n']
|
||||
check("no-op when magic already first", m.colab_cell_magic_fix(same) == same)
|
||||
# non-magic cell untouched
|
||||
plain = ['x = 1\n', 'y = 2\n']
|
||||
check("plain cell untouched", m.colab_cell_magic_fix(plain) == plain)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. Notebook categorisation (clean_section) + README parsing
|
||||
# --------------------------------------------------------------------------
|
||||
def test_nb_view() -> None:
|
||||
print("notebook view (unsloth_nb_view):")
|
||||
v = importlib.import_module("unsloth_nb_view")
|
||||
check("clean_section dash/slash -> space",
|
||||
v.clean_section("### GRPO-Reinforcement/Learning Notebooks")
|
||||
== "GRPO Reinforcement Learning Notebooks",
|
||||
v.clean_section("### GRPO-Reinforcement/Learning Notebooks"))
|
||||
check("clean_section strips hashes/space", v.clean_section("## Main Notebooks ") == "Main Notebooks")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. Colab-intro + stale-widget stripping
|
||||
# --------------------------------------------------------------------------
|
||||
def test_strip() -> None:
|
||||
print("notebook strip (unsloth_nb_strip_colab):")
|
||||
s = importlib.import_module("unsloth_nb_strip_colab")
|
||||
nb = {
|
||||
"metadata": {"widgets": {"application/vnd.jupyter.widget-state+json": {"x": 1}}},
|
||||
"cells": [
|
||||
{"cell_type": "markdown",
|
||||
"source": ['To run this, press "Runtime" ... Tesla T4 Google Colab instance!\n',
|
||||
'\n', 'You will learn how to ...\n']},
|
||||
{"cell_type": "code", "source": ["print(1)\n"], "outputs": [
|
||||
{"output_type": "stream", "name": "stdout", "text": "ok\n"},
|
||||
{"output_type": "display_data",
|
||||
"data": {"application/vnd.jupyter.widget-view+json": {"model_id": "abc"},
|
||||
"text/plain": "0%| | 0/10"}},
|
||||
]},
|
||||
],
|
||||
}
|
||||
changed1 = s._strip_intro(nb)
|
||||
changed2 = s._clean_widgets(nb)
|
||||
check("intro line stripped", changed1 and not any(
|
||||
"to run this, press" in (l.lower()) for l in nb["cells"][0]["source"]))
|
||||
check("intro body kept", any("You will learn" in l for l in nb["cells"][0]["source"]))
|
||||
wv = sum(1 for c in nb["cells"] for o in (c.get("outputs", []) or [])
|
||||
if "application/vnd.jupyter.widget-view+json" in (o.get("data", {}) or {}))
|
||||
check("widget-view outputs removed", changed2 and wv == 0)
|
||||
check("non-widget outputs kept", any(
|
||||
o.get("output_type") == "stream" for c in nb["cells"] for o in (c.get("outputs", []) or [])))
|
||||
check("metadata.widgets removed", "widgets" not in nb["metadata"])
|
||||
# idempotent
|
||||
check("strip idempotent", not s._strip_intro(nb) and not s._clean_widgets(nb))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. Sidecar-log gating
|
||||
# --------------------------------------------------------------------------
|
||||
def test_sidecar_log_gate() -> None:
|
||||
print("sidecar log gate (unsloth_nb_compat):")
|
||||
c = importlib.import_module("unsloth_nb_compat")
|
||||
old = os.environ.pop("UNSLOTH_ENABLE_LOGGING", None)
|
||||
try:
|
||||
check("logging off by default", c._logging_enabled() is False)
|
||||
os.environ["UNSLOTH_ENABLE_LOGGING"] = "1"
|
||||
check("logging on with env=1", c._logging_enabled() is True)
|
||||
os.environ["UNSLOTH_ENABLE_LOGGING"] = "0"
|
||||
check("logging off with env=0", c._logging_enabled() is False)
|
||||
finally:
|
||||
os.environ.pop("UNSLOTH_ENABLE_LOGGING", None)
|
||||
if old is not None:
|
||||
os.environ["UNSLOTH_ENABLE_LOGGING"] = old
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. JupyterLab defaults (overrides.json)
|
||||
# --------------------------------------------------------------------------
|
||||
def test_overrides() -> None:
|
||||
print("jupyterlab defaults (jupyter/overrides.json):")
|
||||
path = os.path.join(JUPYTER, "overrides.json")
|
||||
check("overrides.json exists", os.path.isfile(path))
|
||||
if not os.path.isfile(path):
|
||||
return
|
||||
with open(path, encoding="utf-8") as f:
|
||||
d = json.load(f) # raises -> CI fails if invalid JSON
|
||||
themes = d.get("@jupyterlab/apputils-extension:themes", {})
|
||||
check("default theme = Unsloth Dark", themes.get("theme") == "Unsloth Dark", str(themes.get("theme")))
|
||||
check("adaptive theme on", themes.get("adaptive-theme") is True)
|
||||
check("preferred dark = Unsloth Dark", themes.get("preferred-dark-theme") == "Unsloth Dark")
|
||||
tracker = d.get("@jupyterlab/notebook-extension:tracker", {})
|
||||
check("windowingMode none", tracker.get("windowingMode") == "none", str(tracker.get("windowingMode")))
|
||||
notif = d.get("@jupyterlab/apputils-extension:notification", {})
|
||||
check("news prompt off", str(notif.get("fetchNews")) == "false" and notif.get("checkForUpdates") is False)
|
||||
panel = d.get("@jupyterlab/notebook-extension:panel", {})
|
||||
labels = [t.get("label", "") for t in panel.get("toolbar", [])]
|
||||
check("Restart & Run All label (single >>)",
|
||||
any(l == "Restart & Run All" for l in labels) and not any(">>" in l for l in labels),
|
||||
str(labels))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 6. Labextension source (plugins) + login branding assets
|
||||
# --------------------------------------------------------------------------
|
||||
def test_labext_and_branding() -> None:
|
||||
print("labextension + branding assets:")
|
||||
pkg = os.path.join(LABEXT, "package.json")
|
||||
check("labext package.json exists", os.path.isfile(pkg))
|
||||
if os.path.isfile(pkg):
|
||||
with open(pkg, encoding="utf-8") as f:
|
||||
p = json.load(f)
|
||||
check("labext name unsloth-jupyterlab", p.get("name") == "unsloth-jupyterlab")
|
||||
check("labext themePath set", bool(p.get("jupyterlab", {}).get("themePath")))
|
||||
# Concatenate every .ts module under src/ so plugins defined in their own
|
||||
# files (cellNav, colabTitle, outputSelect, uiChrome) are all covered.
|
||||
src_dir = os.path.join(LABEXT, "src")
|
||||
all_src = ""
|
||||
if os.path.isdir(src_dir):
|
||||
for fn in sorted(os.listdir(src_dir)):
|
||||
if fn.endswith(".ts"):
|
||||
with open(os.path.join(src_dir, fn), encoding="utf-8") as f:
|
||||
all_src += f.read() + "\n"
|
||||
for plug in ["unsloth-jupyterlab:theme", "unsloth-jupyterlab:cell-nav",
|
||||
"unsloth-jupyterlab:logo", "unsloth-jupyterlab:colab-title",
|
||||
"unsloth-jupyterlab:output-select-all", "unsloth-jupyterlab:ui-chrome"]:
|
||||
check(f"plugin present: {plug}", plug in all_src)
|
||||
# The two newest plugins are also exported from index.ts (wired in).
|
||||
index = os.path.join(src_dir, "index.ts")
|
||||
index_src = open(index, encoding="utf-8").read() if os.path.isfile(index) else ""
|
||||
check("outputSelect wired in index.ts", "outputSelectPlugin" in index_src)
|
||||
check("uiChrome wired in index.ts", "uiChromePlugin" in index_src)
|
||||
# uiChrome hides the right activity bar; CTRL+A output-select selects nodes.
|
||||
check("right activity bar hidden", "jp-mod-right" in all_src and "display: none" in all_src)
|
||||
check("ctrl+A output select", "selectNodeContents" in all_src)
|
||||
# branding assets
|
||||
login = os.path.join(JUPYTER, "login.html")
|
||||
login_src = open(login, encoding="utf-8").read() if os.path.isfile(login) else ""
|
||||
check("login.html branded", "unsloth-login-card" in login_src)
|
||||
check("login.html uses sloth stickers", 'static_url("sloth/' in login_src or "static_url('sloth/" in login_src)
|
||||
check("favicon.ico present", os.path.isfile(os.path.join(JUPYTER, "favicon.ico")))
|
||||
check("logo.png present", os.path.isfile(os.path.join(JUPYTER, "logo.png")))
|
||||
check("sloth sticker installer present", os.path.isfile(os.path.join(JUPYTER, "install_sloth_stickers.py")))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=== Unsloth Studio/notebook feature validation ===")
|
||||
for t in (test_colab_compat, test_nb_view, test_strip, test_sidecar_log_gate,
|
||||
test_overrides, test_labext_and_branding):
|
||||
try:
|
||||
t()
|
||||
except Exception as e: # a thrown exception is a failure, not a crash
|
||||
_failures.append(f"{t.__name__}: {e!r}")
|
||||
print(f" [FAIL] {t.__name__} raised {e!r}")
|
||||
print()
|
||||
if _failures:
|
||||
print(f"FAILED ({len(_failures)}): " + ", ".join(_failures))
|
||||
return 1
|
||||
print("ALL CHECKS PASSED")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue