From c9ff52ba043e5f57fdbed03848097b5aff0d4eb5 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 25 Jun 2026 16:22:41 +0000 Subject: [PATCH] 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. --- docker/.dockerignore | 16 ++ docker/Dockerfile | 29 ++- docker/Dockerfile.studio | 60 ++++- docker/docker_confirm.ps1 | 2 +- docker/docker_confirm.sh | 4 +- docker/jupyter/favicon.ico | Bin 0 -> 16901 bytes docker/jupyter/install_sloth_stickers.py | 78 +++++++ docker/jupyter/login.html | 97 ++++++++ docker/jupyter/logo.png | Bin 0 -> 14416 bytes docker/jupyter/overrides.json | 41 ++++ docker/jupyter/unsloth_labext/.gitignore | 7 + docker/jupyter/unsloth_labext/.yarnrc.yml | 1 + docker/jupyter/unsloth_labext/package.json | 52 +++++ docker/jupyter/unsloth_labext/src/cellNav.ts | 100 ++++++++ .../jupyter/unsloth_labext/src/colabTitle.ts | 158 +++++++++++++ docker/jupyter/unsloth_labext/src/index.ts | 74 ++++++ docker/jupyter/unsloth_labext/src/logo.ts | 5 + .../unsloth_labext/src/outputSelect.ts | 126 ++++++++++ docker/jupyter/unsloth_labext/src/uiChrome.ts | 57 +++++ docker/jupyter/unsloth_labext/style/index.css | 6 + .../unsloth_labext/style/variables.css | 97 ++++++++ docker/jupyter/unsloth_labext/tsconfig.json | 26 +++ docker/run.sh | 4 +- docker/studio_launch.sh | 8 + docker/unsloth_colab_compat.py | 65 ++++++ docker/unsloth_ipython_startup.py | 11 + docker/unsloth_nb_compat.py | 11 +- docker/unsloth_nb_strip_colab.py | 215 ++++++++++++++++++ docker/unsloth_nb_view.py | 209 +++++++++++++++++ docker/unsloth_sync_notebooks.sh | 74 ++++++ tests/validate_studio_features.py | 207 +++++++++++++++++ 31 files changed, 1824 insertions(+), 16 deletions(-) create mode 100644 docker/jupyter/favicon.ico create mode 100644 docker/jupyter/install_sloth_stickers.py create mode 100644 docker/jupyter/login.html create mode 100644 docker/jupyter/logo.png create mode 100644 docker/jupyter/overrides.json create mode 100644 docker/jupyter/unsloth_labext/.gitignore create mode 100644 docker/jupyter/unsloth_labext/.yarnrc.yml create mode 100644 docker/jupyter/unsloth_labext/package.json create mode 100644 docker/jupyter/unsloth_labext/src/cellNav.ts create mode 100644 docker/jupyter/unsloth_labext/src/colabTitle.ts create mode 100644 docker/jupyter/unsloth_labext/src/index.ts create mode 100644 docker/jupyter/unsloth_labext/src/logo.ts create mode 100644 docker/jupyter/unsloth_labext/src/outputSelect.ts create mode 100644 docker/jupyter/unsloth_labext/src/uiChrome.ts create mode 100644 docker/jupyter/unsloth_labext/style/index.css create mode 100644 docker/jupyter/unsloth_labext/style/variables.css create mode 100644 docker/jupyter/unsloth_labext/tsconfig.json create mode 100644 docker/unsloth_colab_compat.py create mode 100644 docker/unsloth_nb_strip_colab.py create mode 100644 docker/unsloth_nb_view.py create mode 100644 tests/validate_studio_features.py 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 0000000000000000000000000000000000000000..f922d8df9e33321def0ceed3b06bd34cf25e795f GIT binary patch literal 16901 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 s?LAfB`?|hH5QHi3tCj!xLj?tW" --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 0000000000000000000000000000000000000000..8fc411695d90ed1170884241b327bb7d3f116eea GIT binary patch literal 14416 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 Hzl8n=HTqeY literal 0 HcmV?d00001 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())