diff --git a/docker/.dockerignore b/docker/.dockerignore index 33f15e99aa..02b6802531 100644 --- a/docker/.dockerignore +++ b/docker/.dockerignore @@ -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/** diff --git a/docker/Dockerfile b/docker/Dockerfile index 70c910ab1e..5ea94b1134 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 ` 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} diff --git a/docker/Dockerfile.studio b/docker/Dockerfile.studio index 60f92a02db..b2469b5188 100644 --- a/docker/Dockerfile.studio +++ b/docker/Dockerfile.studio @@ -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 \ diff --git a/docker/docker_confirm.ps1 b/docker/docker_confirm.ps1 index d6c6042de6..9ee659726a 100644 --- a/docker/docker_confirm.ps1 +++ b/docker/docker_confirm.ps1 @@ -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 } diff --git a/docker/docker_confirm.sh b/docker/docker_confirm.sh index 8e63067263..cab9c22dc8 100644 --- a/docker/docker_confirm.sh +++ b/docker/docker_confirm.sh @@ -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}" diff --git a/docker/jupyter/favicon.ico b/docker/jupyter/favicon.ico new file mode 100644 index 0000000000..f922d8df9e Binary files /dev/null and b/docker/jupyter/favicon.ico differ diff --git a/docker/jupyter/install_sloth_stickers.py b/docker/jupyter/install_sloth_stickers.py new file mode 100644 index 0000000000..a1e2bb36bf --- /dev/null +++ b/docker/jupyter/install_sloth_stickers.py @@ -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 "" --dest "/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()) diff --git a/docker/jupyter/login.html b/docker/jupyter/login.html new file mode 100644 index 0000000000..1ff4d23df4 --- /dev/null +++ b/docker/jupyter/login.html @@ -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 %} + +{% 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" +] %} + +{% endblock %} + +{% block script %}{% endblock %} diff --git a/docker/jupyter/logo.png b/docker/jupyter/logo.png new file mode 100644 index 0000000000..8fc411695d Binary files /dev/null and b/docker/jupyter/logo.png differ diff --git a/docker/jupyter/overrides.json b/docker/jupyter/overrides.json new file mode 100644 index 0000000000..6ffc6704d8 --- /dev/null +++ b/docker/jupyter/overrides.json @@ -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 + } +} diff --git a/docker/jupyter/unsloth_labext/.gitignore b/docker/jupyter/unsloth_labext/.gitignore new file mode 100644 index 0000000000..a51e4ca6ca --- /dev/null +++ b/docker/jupyter/unsloth_labext/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +lib/ +*.tsbuildinfo +unsloth-jupyterlab/ +.yarn/ +.pnp.* +yarn.lock diff --git a/docker/jupyter/unsloth_labext/.yarnrc.yml b/docker/jupyter/unsloth_labext/.yarnrc.yml new file mode 100644 index 0000000000..3186f3f079 --- /dev/null +++ b/docker/jupyter/unsloth_labext/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/docker/jupyter/unsloth_labext/package.json b/docker/jupyter/unsloth_labext/package.json new file mode 100644 index 0000000000..2f53c6853f --- /dev/null +++ b/docker/jupyter/unsloth_labext/package.json @@ -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" + } +} diff --git a/docker/jupyter/unsloth_labext/src/cellNav.ts b/docker/jupyter/unsloth_labext/src/cellNav.ts new file mode 100644 index 0000000000..81eb9d840c --- /dev/null +++ b/docker/jupyter/unsloth_labext/src/cellNav.ts @@ -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 = { + 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; diff --git a/docker/jupyter/unsloth_labext/src/colabTitle.ts b/docker/jupyter/unsloth_labext/src/colabTitle.ts new file mode 100644 index 0000000000..39f3f9f986 --- /dev/null +++ b/docker/jupyter/unsloth_labext/src/colabTitle.ts @@ -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 ` 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 = { + 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; diff --git a/docker/jupyter/unsloth_labext/src/index.ts b/docker/jupyter/unsloth_labext/src/index.ts new file mode 100644 index 0000000000..d2838a5047 --- /dev/null +++ b/docker/jupyter/unsloth_labext/src/index.ts @@ -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 = { + 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 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 = { + 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 +]; diff --git a/docker/jupyter/unsloth_labext/src/logo.ts b/docker/jupyter/unsloth_labext/src/logo.ts new file mode 100644 index 0000000000..76c64e97e1 --- /dev/null +++ b/docker/jupyter/unsloth_labext/src/logo.ts @@ -0,0 +1,5 @@ +// Auto-generated: Unsloth circle logo (circle-logo-small.png) as a base64 +// PNG data URI, embedded so the top-bar logo plugin has no runtime asset +// dependency and renders identically in light and dark themes. +export const UNSLOTH_LOGO_DATA_URI = + "data:image/png;base64,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"; diff --git a/docker/jupyter/unsloth_labext/src/outputSelect.ts b/docker/jupyter/unsloth_labext/src/outputSelect.ts new file mode 100644 index 0000000000..a49db67d53 --- /dev/null +++ b/docker/jupyter/unsloth_labext/src/outputSelect.ts @@ -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 = { + 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; diff --git a/docker/jupyter/unsloth_labext/src/uiChrome.ts b/docker/jupyter/unsloth_labext/src/uiChrome.ts new file mode 100644 index 0000000000..ea0bfc84a8 --- /dev/null +++ b/docker/jupyter/unsloth_labext/src/uiChrome.ts @@ -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 = { + 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; diff --git a/docker/jupyter/unsloth_labext/style/index.css b/docker/jupyter/unsloth_labext/style/index.css new file mode 100644 index 0000000000..0046a04a36 --- /dev/null +++ b/docker/jupyter/unsloth_labext/style/index.css @@ -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'); diff --git a/docker/jupyter/unsloth_labext/style/variables.css b/docker/jupyter/unsloth_labext/style/variables.css new file mode 100644 index 0000000000..c95d90fcf4 --- /dev/null +++ b/docker/jupyter/unsloth_labext/style/variables.css @@ -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); +} diff --git a/docker/jupyter/unsloth_labext/tsconfig.json b/docker/jupyter/unsloth_labext/tsconfig.json new file mode 100644 index 0000000000..a26bcc1a5a --- /dev/null +++ b/docker/jupyter/unsloth_labext/tsconfig.json @@ -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/*"] +} diff --git a/docker/run.sh b/docker/run.sh index aa285cb805..faa93254bb 100755 --- a/docker/run.sh +++ b/docker/run.sh @@ -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 diff --git a/docker/studio_launch.sh b/docker/studio_launch.sh index 9c60077472..21be48bfd2 100644 --- a/docker/studio_launch.sh +++ b/docker/studio_launch.sh @@ -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 diff --git a/docker/unsloth_colab_compat.py b/docker/unsloth_colab_compat.py new file mode 100644 index 0000000000..0cc12803db --- /dev/null +++ b/docker/unsloth_colab_compat.py @@ -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) diff --git a/docker/unsloth_ipython_startup.py b/docker/unsloth_ipython_startup.py index 94e4245450..a4a990a864 100644 --- a/docker/unsloth_ipython_startup.py +++ b/docker/unsloth_ipython_startup.py @@ -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) diff --git a/docker/unsloth_nb_compat.py b/docker/unsloth_nb_compat.py index 8061bac91f..afb62c55d9 100644 --- a/docker/unsloth_nb_compat.py +++ b/docker/unsloth_nb_compat.py @@ -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 diff --git a/docker/unsloth_nb_strip_colab.py b/docker/unsloth_nb_strip_colab.py new file mode 100644 index 0000000000..4e87bdae88 --- /dev/null +++ b/docker/unsloth_nb_strip_colab.py @@ -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 [b.ipynb ...] +# strip the listed notebooks in place (idempotent). +# unsloth_nb_strip_colab.py --state --dest +# STATE-aware sync migration. STATE is the " " 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) # " " + 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:])) diff --git a/docker/unsloth_nb_view.py b/docker/unsloth_nb_view.py new file mode 100644 index 0000000000..92eb4be501 --- /dev/null +++ b/docker/unsloth_nb_view.py @@ -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/.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. +# +# /01 Main Notebooks/Llama3_2_(1B_and_3B)_Conversational.ipynb +# /02 Gemma 4 Notebooks/... +# ... +# /99 Other Notebooks/ +# +# 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 [--amd] build the symlink view +# unsloth_nb_view.py --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/.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/ + 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 sectionfile 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:])) diff --git a/docker/unsloth_sync_notebooks.sh b/docker/unsloth_sync_notebooks.sh index 7e251574aa..6f045ffdfc 100644 --- a/docker/unsloth_sync_notebooks.sh +++ b/docker/unsloth_sync_notebooks.sh @@ -21,6 +21,13 @@ # UNSLOTH_NOTEBOOKS_DIR= target dir (default /workspace/unsloth-notebooks) # UNSLOTH_NOTEBOOKS_REPO= 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= 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 " " for every file currently under DEST (skip metadata). record_state() { : > "$STATE.tmp" 2>/dev/null || return 0 diff --git a/tests/validate_studio_features.py b/tests/validate_studio_features.py new file mode 100644 index 0000000000..f5b0b3bd10 --- /dev/null +++ b/tests/validate_studio_features.py @@ -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())