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:
Daniel Han 2026-06-25 16:22:41 +00:00
commit c9ff52ba04
31 changed files with 1824 additions and 16 deletions

View file

@ -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/**

View file

@ -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}

View file

@ -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 \

View file

@ -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 }

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View 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
}
}

View file

@ -0,0 +1,7 @@
node_modules/
lib/
*.tsbuildinfo
unsloth-jupyterlab/
.yarn/
.pnp.*
yarn.lock

View file

@ -0,0 +1 @@
nodeLinker: node-modules

View 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"
}
}

View 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;

View 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;

View 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
];

File diff suppressed because one or more lines are too long

View 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;

View 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;

View 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');

View 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);
}

View 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/*"]
}

View file

@ -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

View file

@ -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

View 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)

View file

@ -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)

View file

@ -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

View 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
View 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:]))

View file

@ -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

View 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())