From c9ff52ba043e5f57fdbed03848097b5aff0d4eb5 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 25 Jun 2026 16:22:41 +0000 Subject: [PATCH 01/19] 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 33f15e99aab..02b68025317 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 70c910ab1ef..5ea94b11344 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 60f92a02db4..b2469b5188e 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 d6c6042de63..9ee659726a4 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 8e630672633..cab9c22dc85 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 00000000000..1ff4d23df4c --- /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 zcmWk!RahHq5Dn7e?ry=YxVyV+kpjWBxVsc67OZ%&;_gmyr-9<`P~07E?!zVz`+d9r zj+`@RW}?+q<4 z0bX8KPBsnzKt4K0%S%7`9$$3h-V#Gsa`B=r-5~@(O;d?Df@x5I2?m&jV`7UBMtnoo z#l^$Qme&~wokNz-`L_L&q19R<=9^)xjuHPE#cD}YfbaR~>GQ&K=Rx+t%aqSFMlcpO zUDAGm9Y8=cLRfH-B0)`lV6-b37EgW?o)U9^ywCc>AOLUy=i?pdUMuzjQ{xPP1-Q(y z0kMa--ox6}>GS}UNq|ji>;n{-L`}d5oAwAUV1y7*tJMHP2BZK04nF>p)PQm{z{!n* z=m)@JS@J3YV6lL-3=t3q1IS>I1Hy`T0KVviXtKce_5prYic_<}_g2FM8#Hsu!3KAI z0C>jhCt`?y0R(4AvP=Ld`C$Q4)klQMNRd%cPHD~ayFTs2ioSDgId8@W-a+rat@vI5zGjMNRP`2yy?!-w z@~qa3KNyFXab8lS zy&@%hp#dr*)p}K%5t_ywdIQg3U}MqsXFLJof!D0xiBXaOnAY5!KKB4Xa?fLeI5P}j zGQDUA0O&j_yN1P1e~<0BiP9S{xPFD~u`maW|M0 zIiE01f=Ihpa1p+#7LNEQR^}FF<8Karw1Z7-qmW}4f{$MabNdK8Baz@GMi6n#s06=Z zna3|uPazUy#KTjY$#a8)iztu2;c8G1j<^=VmPy(r8OzgLvAW_4#2JY{C9aH6a>l&G zbjoq0gw$)m{e|@qZ!4uBjv4yt265-cFb+;Du!j&gl8Xd47nDGh4n5of0Wt9w!4M8~ ztM5x@M&F}kzC`O2(+%TW?CJC9Ch%C0>GOHwk@V)8og)HaV}o%{5KY1{`y4_O6Xo=k z^`%N_PiPvk+Y$AU!ooTF=&6%S)f#Diu^$J#%?0Zc^<^vR&Zx+#a%oNI(Q!s< z;9XT@D%wO&1r!B)`4xHABCR6P8BE)3WxC@;$IKx9wkM^r-C zLd2e-{OiMPdd7(Mrv*-u4C@~lKb&>+neLeU@sh@{Qw39XQv*{E%C&Xebj0R-%2Bi{ zbTBGRbOV0L>NKeDlp+1aub3+%)9EPVt)9~K(o9gF)-WyXHhm3!vOuekjF9~qDPO2< z(K197M}lQ8H* zb(LO;yEliw}Os> z_Z~>?yzLt85$!7;)Bi+gEslMTkZ#9r(T~<3a<`5*g|`I%&gah|6nvk!5qT4d960zb zE+!H!4()%~wn8q#w@R_bZQ7FtYziR?h4ig-XHnKWJl+JNR+Y}A=9S#*maF!{X6Ck> zD@6lEgS*{~lPUI@R_&0+S@(&F5th};#mdo>c!9ZW`&_f`rOuU?xQJqz>_Np?-q_)^ zGHTo}axQUmaj(Dqbz&!{B&($FI`M$r#M8vUf1YnDt1 z-WXyIo*~K8rwKY`@_DTI$Z5n4%s}T~kIF|A&4}MN63o>m_W!W02_2AMpb_yoLTIq^8O2lSBk= zqq$bmm7ZU(oL>66#43=IoswOsiDsMD$h`2Wg`q{wikUeXZP#btaf~E6YV3ZO=F#nX z1%vs94Ixn26)XWbwsf>hH=m&(%Whg#Xz{cSx}(>ecJAIIh8b;Xvk(_{JL zqGPAZdxM@D#V$LExj&H?XjLSaaeE}Dj*9E<`M!3y@so{{W0P<^W;~~#Cz>l)uJU;c z?|gHDjK_6ft14Q$m*|$~d)B=CuF=ED@PyXSQ%*XYn7#GvyJP}xoo3EL*Aj@$N!ok% z1J_#RywnDB6a0$(5bk8okL}!@9ad|4dg_rJHF@L6c{m?uFm_h$}+$mQd1X!1@8s3bQ>RZQe>#;VV~@=J!jv4(M%wbXR37=inla*OHx z==J(Rg7HPC%MJCt`3h}|Qk6)xh+Lq{>y~`YXXj(f`|(pf;cE9Am17~u`E1aWUGJRR z!ovcaah^}t`OpbTixb@%X)A5}?StptDS;RAnewW!Z-RHtOZW2YKZGZYe?g6Jb(X!3 zn-5=lzfAWVV4R~PMdiMwyw65$&B#s2&C$rx#3kfVP>`SzcU^&|lDA6gOK_&g0(IYg zj@>E_HeR=%m8SJy7B5g9W2bwneafy~&nwDm1czycDLSWoL@s~5R86e}oVT90Zwz-) zWH7vlzo}nCo-Bq-{;1uH9{9h#u-}JYKeNj5PeW@3~-HQ-#!Sz%93`Has$L*YE;3$ZWDIKLV$&xOl6N7jx0MFX( zLfbRrIF^8w9hdMj61ZVswBdKv&z`{+k!C*%OU@hqy}oJ~&#iHZC{N3CKSvZC`&ayX zoOkP-_M=oWzNLN*p@T2~gBi>OZ|GzUhY3p+9#{Ymn;6ke1p_XsQP>YmOiFZnwlt8H z0E3FOSUr!+BxfTFqLXPAoWu)c#qkhJFy)`t#3_MjN%+KMu#jI!RR&sfA~DkqH5NNl zIaT8eHL<5+**33-T#ef8rspq~y;rI0#+*frnAn_PPK~5J6g-1mN~J4Y8Suw^WRQU@ z13(b+aY2CkMhuo49`+wMyd6*_MAD6)) z`K!&OnzL6W@uzZbc&bDo(uYS_18}#aPRcPWm{UB-62$0L_NOMI^TI7C_8TT5_qE>P zG!=K_s+%2RI2~q8_=$&ES(!MVA+JwkfP~FtvjD4GIty~Zg@JOZVGro5@MSSJwJQ^q zk~UMlYl{vz2$Ymc97|nDNk!w)kudVgEFEYJXRL+HBZZg@2vH5C3s;bTOJ8k34xfxE zZicN)QwGhR5eTJM`n2!dV(?mL8a~q|?}V_&VRDB6M+;fOH|O{=k7+u(wep;t*fF3( zRSsz^tRJCl^T<{5Ldveli2pK3Lw?yJBJDDsoR7xVYGPYu7h~4_k$dlUaee8lg13sA_RY}q$ct94> zr&|K&8yk<$ADrz^w*p5?#U8S-3XsrxYviwHbqyZ7Wi)F?uwx`-;}N4>U*Tm;dn6KN z*Y&=c$TLs_OC*>wRcmcw-3D4u7^Lh+Nu8d7oPH3oArqr|1Ain)hU>QD#=nJ#Ul)p% z$A(lv8^0KsN$V{;Rdj2aBCQUs6#r@J=I3Fy%_xh7T;9&^q_bm|N}9Hl>x~LLN{WGn zdOl%(Ld3%^B}#Qr!aSM(J>{JDEVWXk;gNynw=uWeAKF6|Ul@t_YLBO_4}5g2U{R}+ zN5)r>oU)COu9FqV2<*dDM>4~ELTVb(w*A#V-+nrdqkDLP#>x?W_@uErg_YSmqaL>F z8D6N?!^`@#8jA7Ecy97_m0Mbh66q+5*WmGQHG`C6{f7#cEV}ZF5(XLRx*!J6ze9SC z4hLeeQYh_N#eD%xgL}9c-312_3PrA5WpE-02`h$5lc>Y(pTR~$Jc*eK`^t@8lJFbd zNvn;#td0By1+T(mvV(Ll==VQ&G0GRPqp# z4a~PQlo(kRv6I@(D*?JDMOyAsHqQSU{!Q^VY?-0*41UG_g7MH7UMfjR!&*0TMXPHt zS}d;kp>C~-+L`XEk57T&6Z=Q32;QDVK=2nj1vgGf#YyvI)`u$elbBGzves8{86g9{ zT~44wy`*gEV?sf%UTQ>T3}-3v=*|i1>rI;*j&o3bv8g0WNMMPoD^Y@7EHKC>HI*Z# zNKQR6@6%LB_7g>+D3vP&jCJb%`h?b#jKze+qa-=`V|ghpK3TgVd%J2YjcUi%<$-Xo zY9C$?kZ#`3G)gM(UeD|OBW{ak!f`cRrB2h#D+$ZaMCb{~^WoM-$hDY#Gw5jw&DC-6 z@8XHf`K?}B#9?|QqWG+I7>hzhRlV?^QZ;v4>T}Y#Q#Ob)3|6GV*xct48)6O~6*)l( zV3s5-tS$l`F9Dt|0yd_`tnv-U?iCD8hNi%e;5o=~1H0|oI8TL%Y%)+7-*01-A-tT* z$5=md4lCn!?B>wh!-qJ^6mbAV=*j=ArSv z`df3|e~aQ`-~gbVv^P)(Npq`us^P37IA@1Z9Ax4%A#CS>c;d7I;2x$nXIssC*W{SPLOsXq|GV(fJ2L7U|4#sSvBvOxm17)7XOL zCSqUz0ubkD2pocqg9DHdb=zIg*1=)-@gUFo_TSvF{{@fze68aO@09ShvO`RS3#TL# z(I9^5Fe5XFyXk#k1e-dQs-ptyJdAI0VOi3eexKcRDX*ZYLa#^jZ4}%nAIgk;9}SjH zLAOAYtCHuO97U`;;@_wg$;kW8pRCmh*S_X_f40VAP#+$FLQwB@ZenO?h#2*ptA`8^ zJCVhg5X;s+vU)GEBAUC}5hJ6L1yg#heL&sDo*1gfv&%#Q9$vzJi5gZ@s##f^S)eX* zuJUPGb+d-yii@khMH;aeH(O`S!(R{wk08f6Zt2RlGe6ETrn@-J{VB*g+33kD$qQWO z78!=q`lW34JM@FLo}QObGwo_0D+zvWvn{i#8eU*WR^_j|cPj%t%Mn21-No{qJwV=^g)Au=#i ztn_>X8zAA~!J}syz$5lJs>Z*Uq7@V0wb&ZnF+WY8f~c`qGv2gu!w!o>gm?VItVneA zB5Jd$;jNB+Ax&!J%NOQNR>$1-C#1<6g2;?|MP$E%Q+_<=>F-_HO7^lpBKUZDUGLYt zuJ^N?@AleyKK)Y78N*Q%){vfnXn#MlH`(j|#p)dZ=fZ@)wsDIf`Gt(@`$$lyr`FBF zwF%pOy{ly92I9rP)JFexMYUDD-4xe5J)9pr-a3f5pk_J4GB3Rx6W$jx3C+uGEfUBSqkhB7d^vnH26a-qI^;t8>m%rQ^xP z8G1d!h!t7>O&$f}Kx1_qg^IiJ6@01WIPEPr3wYv5ovY;W_~9`7GvW3P9A>mM|MN*h z+w)n+^K1PuO3=#=#pRiuz@*5f46Lh^SHOhx)9IVf3De@)y&p6*$F>2PJ& zlHCY&R2zns{A3!8qR5x$X`fEHL1&hX#~b;Xzs8sFntm-&OTQ^6mym?%YJoJ=VK^Tv>yRs z_Ke$0e^oLzk-Bkj-je%nW9M8M<{Op%A=zqkOh(hW>lROd`#fgl| zY69@ZTn0`f|H^FE%1G6D10@CctwdcGwUVyTvnbu!WRRm#nwP60QelQ?o8E3Jx`HrJ zf-9mLIYd#F%heE!M=XgbIPU5C$EqjP8`Rh4Z3y>yaB)^e?> z;H7SH{*WYwA+>$x&o4xk)ZJAmJ;4C(`j3piXci{5dA-&l+9`$|S@gq3R``Jn#$I*S z)472$g@h(G=_F3AAyZSTPHlT>JDOGkt*6Z^{I1gH?|wS3Hr;cd(xzzi|Qx8iKQ)fWMnAinkxwOdiMri&gnKh|2MRp2zV#~dES=_YwT_j;F$9q6!) zb{~y-pVI@tbZ`$u8xp)jy4X{!=A{6{IA*dw=Xk9f+%=^1oh&t| zOumc`MF=Oq%;^qL75V5Z>#n3HLki?QVPEGxPiMt&ssibtF?GiuOQs#BH?0 zS<4^kBRZvp@0Gs_?1<|eGND{Dj=O7-Afn4lghQ_iT4{Fdyy!#8_Sw%E2Eu)TlBx`7 zHSqbg^Kyt}ZG@1|iFCoFBI-u$36k(IoA;(Ye|-}^OcAuIT=(L=i(B5w6FxJ1=AyY6 ztoQd?tin8x`{}s2I!PrczQk`aB_5a`G3OOp|w3o79EmqhElfac2d z&g&^5gAPwtD1EaX`v>Ew^8Ypp+6+N>=%;v7mre|!cs-PpS!r4KWzGwHvK__s7g%ht zPOuG)>Ztf3dZ-PKrgV+QC>6p2t4haXRndHRd{NLF3G`~Qx73+c=b0=(=Ag-ool!=} zW&Kte6TgRuWDQ>Bx2ZJP+({5q{X}?j`+>s$;&**C^S~&5-o(@G@lvxRNzcn3J50MR zLNA`45yWvL90|w9(Q$8x#QEep045iD4!2joFg3Y4K?RTK)|iQ8jh6Gi4E%TtG7g){ zoo%E8_pYcUK>=gDg5TU)nIjp@5Y|jhw_$F|Ocky33s;XWlLN%4{+H?Z7quF8PiRJ5 zE?OjHtVkl(FIBFOwxMPRJ{`#z^0@Af6zmX~`g2G{q8?p7rJMLh^KJO!l*}5;zoMQj zH^mOd62Q60?tPi65DjtRgvGU!$we&F*Kw7jEi(FC2jveZ)40lVVy5lYK6WwZA>{cc zQpUCbNyNBLucyWGwA4gW&H5uDfBy9Tln5i_ zvBTxEo+@faupS8n|bz zJMr~3SzOlW&YhRCO9wqr5OjBU2e^3dVLI?)5UGsEKI=;-E&#FA%!#x`rZ_>py?xh~ zqn1)K%G}|3tTW%YPb){P1B4nBBE2hG>(7AV&M2nM4hA*sMYe`ZzmD?WAM+j`r-Mx9 z+n`3+eS1E)K3i?$=H^cEhzqf=Zi}3&(7Pwzf{`xNn6ET|>wSok$Tkgt{jFUYuA4&F zCruZtxmDKmoshr4r6pSxC|6O>UH%EoIhyai;(#Yj52{@o7mvdD)5GmX_*v{roqp^6 zx}VSEUV7Wh)%fIVg76h037^x+t#4HS@0!3{=I-07x7+f2x9hxdL9bCT?fw&? zGfycaaMr=2xZnq(!Wf3{V8Is>x4y;D{*mZHG7D~*nWo-veUGKkOoreB!9f+f!1Ut6d1exq`%l2FA z2xq^mPsLh?*X;bfZlt`T#b$4(X_kl|Z!7`(klmyg7L(59F-qUorAv?gN@?q|eM1O+0FHDh*AFf@`QV;6NJ2(2c5NGQdwL!fZXe$uom z6@?CiSu61M^GWFkfhzDmQdy$~hBs5HT~KPBk}ein=AS>@%GBD)h{%zFc%AZezYXxh z%0`+H@$VK7G0@3{M{3RbTW{w-E0OnHjWKte9v>cVmX+k1-<>QCPwcMy9OPJ;Odh$r zu6Zor%^9F}KK-lsJ2zM7eQEC0{kZQmth7e)bLL;s`Waodl`jzXaDSH;phLOjfCrv+ z!ZwG5mojpz8E)yvvq}H>P+{ z$fUK$&!hgQ3FLy1YTXKE5ioF6{vpoM057xQk8m{Gei{AWjtNd1XJ@gY3?Fz{7$zpB zF+qYG_$?@5cAWP7&$E&z0nc}rf21%xgq+tqefFOkrbX?+W+@`JGezKg;|8lqj?v)l zmyBUMDT;h{Dqq(VToP8K05$>;k^=Z-$Oj)SpAdcJ;S^~fYj6lj^>r^CvrDuy1WH~B zA5ifB%*;so`3c3v#r+Rkug~|htE&@`Lb)W%jNf4h*U*Ie5sgFd$t;Da!Rcc#mOwsLrCjYy=qC`C0(6F<`%hA`&#%WgQ(^o0^)kUB?)WJeMrHO!Xf)!2?iHIvvZ7 zk1?1f?Bm+vhK4i0zCJaU%G~y_9h)KGMyDontDSddfE)up55S(bk^m>Q%hzW4aBfhU z`lQ^#S^3qngpW+dX_0eN)s7)g8L4$cMLrOl@pxIE#cKJ+^%5GR7b7$Y?ko3k&i?(Y zxf~X<8g{F_H%GIR?jz8mWfWS#_!*4=M7X|ex=@E3{b{Hx@)@f-lmf&7iz=Ou$-Dsp z^F^XOy!MA8IAm?;E;%_lRw-`V(e#=+Iv$ndkN+c4`pRXj=}Tzd`^&-F483#DD{r)P zC^RoFXO!4eJ3SX`&8!N(9!%xPi0;Mqoz9b_hhv5}Fjj)AIfkkG2nm^$z}-1{*Mqy_ zAGdE{U}NY$)aqZE4#n}UFILR1*P6jiLzVi{J=d}b@-Y_l{i_}e#+^_UhGIM}?FX79 zc@6lR=Z*L7X{$O?LJAWlCRuyIcr6X+_;_9TNHRU{Hal{p%|j*_DBahp`f)6`N(q() zEXkexlQF-9OjYm*s4DjA&er{4&enCKDf<3$Z8H8i$R4QS={Gzbc*iBKW=oj)$?ME0 z@Ofp!b(qWxcTQq?Wd*9+tPXhLpt!Z4VV(PX=hCI~3y2{8mqj?ILZv{@>Y6YSp3G>r zwgj&NI5DX{IHNxaYg@eK%Ohc}JT^$H(~Em!!)~qJ0}Ac9QT-aanKY#6*BI{?7?x}2 zl?_wo`^o{pH7K%wUpeJH#$aV-tu$;x{-ZJ@aB#?45OKHGD{{XoWpB@X-g%9VA^JeM zGZe>s{8o&K2tT1czOIjulL6#4II_xBuKF4LeKn8>Y0xeN)NUM0r(B$0|0TJoku#yt{i+)@ zV>Ak7>nw)RBhksmDr(Zm@%CN#e+YUoC5YS+Ldg&WRX;B`C1RGn!S%zlfKP!h-u|Lp zOJNcBVkp=8n2)HedOC{2Ao0(A2H2F)7^JY>U`=KZ@Q=0mpvz_TV#QzE?{F zeL0G<+PuYDV9*5y@wdBP9-F>rLb<4?(#?9}$M1Zps6!}P{>hg*S?frLifW9k$w*At zin+0g46db~pr9pK(XDVC?|)?_Hnz4LDi#9JQYocv{Re8uAhH1Kp0sHyUqD+{`?e8wfF)}&hMlGVAI!s zdav!>V%`TiUQkVuo#DxjfJ3MJiR|eh8WVByFKlsm9s^LGqe)g$7q>_|b1_Zivzv@P z!1Vz_keGdk23|9~f>D%!bz27hW$`N@+pl|ida561j0k!v2{Pa84GE}*+gKPqYFEd?J75t5(LNp9i0*3?AYQ%=_V z?SZBnr1hVa5|>EF78tu3r~s3blgfFbIb&mrZ?~rJbEN1OGLZTCA*g1InmuB%1017# zAF7Xc4=t{GF7@!9SZ-mv$+jBtIsNpG>+rdY9Gu7JtW0Vn3i~5o5C0PPs7WYooZOnd zAFD$O2bP7GIQencyezOC47?qmLI{%}4}38DcYOS~ywE)ahaueie*=u0*nT=!MEG$x zIooYY;P2nRH;n1D0OtUc=&!At;__~T8FzFTJ+ePr5E7E z%Qg;+ct#%-anI|6&(cg?a5BU?)ou$U7Vw^fL;0W|@$T7*S+P(Gt66DF5PRdt5WWEX zA9+h=PI?ZDwIOIEyr1mnS^PyQpaLYnxj7BWWm;NVVbcdRS(DhPA8qaI3d+i&p;!!o zo~-6MWW76ZD}o@C?9Qom>*Mv}*!(MqiwFdhNq5B>0Uoi3r_NKJXlhZauU4SWz{;On z`EGDH;U1<@Sa>xU^q8@?8yPffIPOd{;-OK@O1^!{xUom#K@aDv2QlvO+>1$D>cf-@KZX6Vfr>-nfji@>#2TOuve zU({oi7kM+zX{!c24?9uaF%Yzg03Y^0I>$1Zn&LrQJ44XAICOk(w`E$iBEb>&qGXME z$V9dPsy5jlFYGDRxtY4b`h8DU+s@c6Vx-dEGG2%JfB4|%hPDZ zx}rgEa9>0$3Gt>kdK9E_EM(f9`+Jr(Ii@Za8umje08b8RJHrVGH_OjZ50q6_!f#$| z^?xVi2nE%9*Nz`T3ZVkJ%3x)q&O$D4D2~+G$n&U}CGcDXRVY!JPQZN=Gf6shxP_f2 zXY|vmPBe5RWx%SB`@tX$0{Rw%`qiM2HEYwQG^aL=&N6v)5yYa3fCqQH=rPRBj#j<; z0Mjm|w&w(#uwt)j!dW4rA5ViwmBgr5YL^s{Z`BDduKE1<`X(s*{88Q1bQ&rc|8I5O zZv+KtSb!IfEuZgB9o9RSR`sAaGkNS|mOCpdDvk~hKRrY4BB`VzH{Autow`oYFvPCT zQAN$*&x2aC9XswXm-fj{#8&&>=6UG^jvr+AqQBpxfy}>PT5F^3Pn1(-8+*YX%*X8( z{4UHmp-`6k961(dMjT4)VJ2ZbG*tUU)fjNIB;;q+bD1apA+hL7`xteZP)UyQY0x&p zbG;u%x`<^6pb|Wh6Oy7u4X`(qj+uf^v^*dUA(`WFs*s)@wK}!9cE;qGSQbKHwC;8z-7nlxxu* zgKq4H^x5cfv*9E6O;>&wp_4@iT?bwg^eD2ZwRo8%2=fBATtaib&n#UgI+-L$+lZIm zBq3oc9$mNcB=(+}E`26Yo#wnO?2`-v@%qZW+;kP~peE#8^#_N+t@vW_uGX9L^m|%4YGD{+A9&wksr%5wSLI)&^?Ur1Qj#aHj zo>Bc5tsA5O&`&aBDb5riyvv&VDB?!{#!AN8T4{=XGrX^( zb`owTjU3S#S+7@Q{DHPJ+Df5g=XztRipmP1rbx;h!Ar29oY#j{LeV}Mis@f8ivxUe zxs#u**WeGOewL`_ET6tqRJX@KYjgLNjoC*4zRVUx?hOqTv9Mt{uzSr0dMRosEiDCN z#U?70)G}~-W|2i1zdz-)nYPSE51;lqHBc!es#<@e#SSmzz#iqU^A4T9Keb$_HimXc z%kAutA_eDknA5sC0w>27a3G5%!FXNe5F}|xfE<%tq#+nHcr2abDeN}ItfeAqW$cdD zYPOnXHt9Q9Rp!Q?r*^Rm)fIss)7C7wf4pYccoZ#6^}|oD^7Ot_+VVI#?{ZRyQ|GKtgnDrZNlG{)5BMrT_8{!E%0`bL`$0!1njY*PNfX|uVIB>? zR4L1kZFXNx)<`N4x^fL+DeukNzS9iIC&fadG_D0f+zbjzGPFGf zf2p)-*`q1!VVh?cBZU~(ZP#~vHC%fjCHLnQD*sdJ;(aJT_`~gjYoBbb-(F?K8+0Hy z@#@b?-jQV(g@I%fhfr4A(OPQ74+d`CKdU%;KWs(C3M#Vj*M}Xaz0u!x8?q67!p@qM zb#rT20^4jt=ySJHqP4u=iUr_>gud#7fcenb+lB8M^qc%m_+V<%$B@OE=2EqF;9Zu< zpiUB$GRyqDV&i06*whs^<&t7Ut)QiaBjn{kPpp(EU-IaFI%XMU@acTPui5F*jV{Ly zPWZ690OwnNRw;LT#@J7LS=$nmZ({`H{(l9({uQh6gHF(&QTZb#pEc8SbQO5dOXceY z!aNiww_5}+3{Kld9d6Ek*LlU{HH{UBiq`kegu`;I2 zclH@7eiDqTsGg^!j2;`dM-JvcJCLIaDB(#_TigtP#_JHQPrX$=>t8=o)x(-^sj}36U#XIlWh=o+SczWQ z>pQ>2yzu!d=upIEAeqEK_8W6cKE;Mbl{=R*S3XgmPN8^4zCN@_f3d@hk6F~H-EOv| z!bE`{o5T&axiG3W(txw^_XUOwc;C%Cx!7jlP+*A&#i zEi2K(M+4{AnnV{n?#rW~dT0*2!%Kx1a1_)L*6D|Ob1G0L0m;=UnUUE{gK6Z@uNUsu zO*;OC6}!zu4;WraOON81h1Ug87o>bw?DHK{m)jc`TcI`|#VX)3*A4XLR5LC+^ljy% zMs7h#c4O4Z%UZLnql78{6@tmY)TpS?$&q@Tpsh)uRjb>I^w0?fmQ^+pquX|mxKdR3 z3-9dGa#;q_eAYz;hU|rJ(o4{MYn+v@Ug&Dt{ z1*2u=7_qOBF5+JM5tuP#*x58`=__J=`TAN3YSp0u&6S)ImPWbDju_iVaYP^tn9hb) zLPh@~XWvdW<=17*Wom(E}bSvQN6;Yr!S|rFW<_ zr;Kp^!J=>ovS7cNZqUo`rv&_-SG(HVLq+%B%aA52NiXWBO042y&5th_LFeT6UhF}1 z9RWc|L02tQ1&hFDQ$YGctG-2P!Q(13 z&VvW8TVFdaeU0G#P3)MMFa1P7b1aTzIsbpSoP~4&hxykv?m-+RZk>LdQecu|o$sDp zRyteuy{4SfxShL%&>w|Q-wug+$Ox!?!IK>M+K;7yuN&)ncFb&JXZBM`SC*Jbv2*Yv z4Ir$k}LEa+|4iBqsV>!brG7)*1V*t;r92f2}NV8{XK4Oy1cW84y^isrj1b`_(Q(`OjqBW}8e3@@k83G~&z7{du!7 zW5Ibab2|KFo_!EPxfMag$iDDGUwFLOL4^{o)&Q7##QCa6(8l&-P1mBo%lq(Q8JhLk zg3ElQEtC7a)l~G3vNWU5Em9iJzZ|%?```Dha250=UgX>Xie;e*GSYn4Kq6Vinmffq zbYZG`U@}s7P-`ZVB3iVfG1rR;##}0%w%$j8&FNuL7mYkWHVtVkR%{}o=(hrbi50h9p?CH_ z?Oi5~fF9iEu0$wUeeNgWIy^fQRKpS0fDw$mD#kgt>5|fXk2f8FlAO$q*PEUk0=0)g zPc<6qjE@FqveH|+NqOSu4boItL)7j)kjlXyf2OXj) zTVEOBq~5bD@R-$7ZQ$vajPq20?R)&G5@b>%-yYo`h9vHFp&jyFZ!H5VR1V5RC3_odZ;pHk&y=k3@tUPQ?Ti(@`$G zTv6nCnFj8AHN;ifkYNF3&i3h*x3oeDZ=Qy*c!mZNlY^8o=yzInZv8HNgss9fSaN&M zT$AxGmfD+6QB3{zQcsV?KEuP0X8l5Jw{k}_?ZKviki-j7ee9f_>iYT5wEMcCsoGWD zYQs$4G7)4zkrjs}fp>qA{Ch%caE@D1c(Mz!h5A+J=i{k&wkmYb17bs3G90VUQfbn( zeVA3#&F^*Gu9gnVdiaYmZk^7|M!L*MYhn3zSxd( z^-rXW0+h8s->RhvsZKT`Z2utzX-T5;562N|f1O=8$MC;C5vmkTpmD4Hz?vqVwg-zg zN{=SB1Pp22d@ok*J9bZMQiNw>N~+}5tub8Ze7_ZNu6jHS^9t@ilm;=(e?xs-Cw-^? z_UWP+)4W%}?lAb@d60$GWhk$EmgKj3T8!p_gh=eigbQ<#F zjMlF~nl$a(=Mk7Y0>~c>VBr6JaQ43>E`H{D&7<$@eMeZq?^rd?AWw#Fa|0A)RHdsW Hzl8n=HTqeY literal 0 HcmV?d00001 diff --git a/docker/jupyter/overrides.json b/docker/jupyter/overrides.json new file mode 100644 index 00000000000..6ffc6704d82 --- /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 00000000000..a51e4ca6ca1 --- /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 00000000000..3186f3f0795 --- /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 00000000000..2f53c6853fb --- /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 00000000000..81eb9d840c1 --- /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 00000000000..39f3f9f9868 --- /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 00000000000..d2838a50472 --- /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 00000000000..76c64e97e1f --- /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 00000000000..a49db67d530 --- /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 00000000000..ea0bfc84a8f --- /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 00000000000..0046a04a363 --- /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 00000000000..c95d90fcf4d --- /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 00000000000..a26bcc1a5ac --- /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 aa285cb8059..faa93254bb3 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 9c60077472b..21be48bfd28 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 00000000000..0cc12803db3 --- /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 94e42454506..a4a990a8644 100644 --- a/docker/unsloth_ipython_startup.py +++ b/docker/unsloth_ipython_startup.py @@ -19,3 +19,14 @@ 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 8061bac91f8..afb62c55d98 100644 --- a/docker/unsloth_nb_compat.py +++ b/docker/unsloth_nb_compat.py @@ -28,6 +28,15 @@ # 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 00000000000..4e87bdae881 --- /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 00000000000..92eb4be501f --- /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 7e251574aab..6f045ffdfcc 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 00000000000..f5b0b3bd10d --- /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()) From 7b5bb24900ee98dbaa79cd2e7e6fd91c0e8de003 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:23:27 +0000 Subject: [PATCH 02/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docker/fetch_llama_prebuilt.py | 4 +- docker/jupyter/install_sloth_stickers.py | 10 +- docker/unsloth_colab_compat.py | 10 +- docker/unsloth_ipython_startup.py | 1 - docker/unsloth_nb_compat.py | 7 +- docker/unsloth_nb_strip_colab.py | 31 ++--- docker/unsloth_nb_view.py | 40 +++--- tests/validate_studio_features.py | 151 ++++++++++++++++------- 8 files changed, 167 insertions(+), 87 deletions(-) diff --git a/docker/fetch_llama_prebuilt.py b/docker/fetch_llama_prebuilt.py index 3eb50d67a6e..6e4c61c01ce 100644 --- a/docker/fetch_llama_prebuilt.py +++ b/docker/fetch_llama_prebuilt.py @@ -51,7 +51,9 @@ def resolve_latest_tag(repo: str) -> str: final_url = response.geturl() marker = "/releases/tag/" if marker not in final_url: - raise SystemExit(f"FAIL: could not resolve latest release of {repo} (landed on {final_url})") + raise SystemExit( + f"FAIL: could not resolve latest release of {repo} (landed on {final_url})" + ) return final_url.rsplit(marker, 1)[1].strip("/") diff --git a/docker/jupyter/install_sloth_stickers.py b/docker/jupyter/install_sloth_stickers.py index a1e2bb36bfd..12c1125bc0f 100644 --- a/docker/jupyter/install_sloth_stickers.py +++ b/docker/jupyter/install_sloth_stickers.py @@ -49,14 +49,14 @@ 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)") + 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) + os.makedirs(args.dest, exist_ok = True) installed = 0 - for index, name in enumerate(CURATED, start=1): + 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): diff --git a/docker/unsloth_colab_compat.py b/docker/unsloth_colab_compat.py index 0cc12803db3..be3741fc77b 100644 --- a/docker/unsloth_colab_compat.py +++ b/docker/unsloth_colab_compat.py @@ -38,14 +38,14 @@ def colab_cell_magic_fix(lines): for i, line in enumerate(lines): stripped = line.strip() if stripped == "" or stripped.startswith("#"): - skipped.append(line) # blank or comment (incl. #@title) + 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 + 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 @@ -62,4 +62,4 @@ def register_ipython(): 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) + 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 a4a990a8644..b62bff0850a 100644 --- a/docker/unsloth_ipython_startup.py +++ b/docker/unsloth_ipython_startup.py @@ -25,7 +25,6 @@ # 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 diff --git a/docker/unsloth_nb_compat.py b/docker/unsloth_nb_compat.py index afb62c55d98..621a7ba4cd2 100644 --- a/docker/unsloth_nb_compat.py +++ b/docker/unsloth_nb_compat.py @@ -34,9 +34,14 @@ def _logging_enabled() -> bool: `[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", + "", + "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. diff --git a/docker/unsloth_nb_strip_colab.py b/docker/unsloth_nb_strip_colab.py index 4e87bdae881..83807a2d1ce 100644 --- a/docker/unsloth_nb_strip_colab.py +++ b/docker/unsloth_nb_strip_colab.py @@ -60,9 +60,9 @@ def _strip_lines(lines): 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:] + out = lines[:i] + lines[i + 1 :] if i < len(out) and out[i].strip() == "": - out = out[:i] + out[i + 1:] + out = out[:i] + out[i + 1 :] return out return None @@ -77,7 +77,7 @@ def _strip_intro(nb): return False src = cell.get("source") if isinstance(src, str): - lines = src.splitlines(keepends=True) + lines = src.splitlines(keepends = True) as_str = True elif isinstance(src, list): lines = list(src) @@ -104,7 +104,8 @@ def _clean_widgets(nb): if not isinstance(outs, list): continue kept = [ - o for o in outs + o + for o in outs if not (isinstance(o, dict) and _WIDGET_VIEW_MIME in (o.get("data") or {})) ] if len(kept) != len(outs): @@ -120,7 +121,7 @@ def _clean_widgets(nb): def strip_notebook(path): """Return True if the notebook was modified and written back.""" try: - with open(path, "r", encoding="utf-8") as f: + with open(path, "r", encoding = "utf-8") as f: nb = json.load(f) except Exception: return False @@ -133,8 +134,8 @@ def strip_notebook(path): tmp = path + ".tmp" try: - with open(tmp, "w", encoding="utf-8") as f: - json.dump(nb, f, indent=1, ensure_ascii=False) + 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: @@ -157,7 +158,7 @@ def _sha256(path): 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: + with open(state_path, "r", encoding = "utf-8") as f: lines = f.read().splitlines() except OSError: return 0 @@ -165,7 +166,7 @@ def migrate(state_path, dest): out = [] changed = 0 for line in lines: - parts = line.split(" ", 1) # " " + parts = line.split(" ", 1) # " " if len(parts) != 2: out.append(line) continue @@ -173,7 +174,7 @@ def migrate(state_path, dest): 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 _sha256(path) == rec: # we own it and it is unedited if strip_notebook(path): rec = _sha256(path) changed += 1 @@ -184,7 +185,7 @@ def migrate(state_path, dest): if changed: tmp = state_path + ".tmp" try: - with open(tmp, "w", encoding="utf-8") as f: + with open(tmp, "w", encoding = "utf-8") as f: f.write("\n".join(out) + "\n") os.replace(tmp, state_path) except OSError: @@ -194,10 +195,10 @@ def migrate(state_path, dest): 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") + 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: diff --git a/docker/unsloth_nb_view.py b/docker/unsloth_nb_view.py index 92eb4be501f..ecb26d70eb8 100644 --- a/docker/unsloth_nb_view.py +++ b/docker/unsloth_nb_view.py @@ -68,11 +68,11 @@ def parse_readme(readme_path): filename is the urldecoded basename under nb/ (literal parens, matching disk). """ - with open(readme_path, "r", encoding="utf-8") as f: + with open(readme_path, "r", encoding = "utf-8") as f: text = f.read() rows = [] - seen_pairs = set() # (section, filename) already emitted + seen_pairs = set() # (section, filename) already emitted section = None for line in text.splitlines(): m = re.match(r"^###\s+(.*)$", line) @@ -102,7 +102,11 @@ def _ordered_sections(rows): return order -def build_view(dest, view, amd=False): +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): @@ -138,23 +142,23 @@ def allowed(fname): # Rebuild VIEW from scratch. _rmtree(view) - os.makedirs(view, exist_ok=True) + os.makedirs(view, exist_ok = True) n_links = 0 - for i, section in enumerate(order, start=1): + for i, section in enumerate(order, start = 1): folder = os.path.join(view, f"{i:02d} {section}") - os.makedirs(folder, exist_ok=True) + 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/ + 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) + print(f"[unsloth-nb] view: skip {fname}: {e}", file = sys.stderr) return len(order), n_links @@ -165,7 +169,7 @@ def _rmtree(path): if os.path.islink(path): os.remove(path) return - for root, dirs, files in os.walk(path, topdown=False): + for root, dirs, files in os.walk(path, topdown = False): for name in files: try: os.remove(os.path.join(root, name)) @@ -184,12 +188,16 @@ def _rmtree(path): 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") + 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: @@ -200,7 +208,7 @@ def main(argv): 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) + 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 diff --git a/tests/validate_studio_features.py b/tests/validate_studio_features.py index f5b0b3bd10d..f4117d30103 100644 --- a/tests/validate_studio_features.py +++ b/tests/validate_studio_features.py @@ -11,6 +11,7 @@ Usage: python tests/validate_studio_features.py Exit 0 = all checks pass; non-zero = at least one failed. """ + from __future__ import annotations import importlib @@ -27,7 +28,11 @@ _failures: list[str] = [] -def check(name: str, cond: bool, detail: str = "") -> None: +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: @@ -40,13 +45,13 @@ def check(name: str, cond: bool, detail: str = "") -> None: 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) + 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'] + 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'] + plain = ["x = 1\n", "y = 2\n"] check("plain cell untouched", m.colab_cell_magic_fix(plain) == plain) @@ -56,11 +61,16 @@ def test_colab_compat() -> None: 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") + 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", + ) # -------------------------------------------------------------------------- @@ -72,27 +82,52 @@ def test_strip() -> None: 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"}}, - ]}, + { + "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 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 {})) + 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( + "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)) @@ -126,21 +161,34 @@ def test_overrides() -> None: check("overrides.json exists", os.path.isfile(path)) if not os.path.isfile(path): return - with open(path, encoding="utf-8") as f: + 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( + "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"))) + 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) + 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)) + 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), + ) # -------------------------------------------------------------------------- @@ -151,7 +199,7 @@ def test_labext_and_branding() -> None: 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: + 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"))) @@ -162,15 +210,20 @@ def test_labext_and_branding() -> None: 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: + 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"]: + 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 "" + 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. @@ -178,18 +231,30 @@ def test_labext_and_branding() -> None: 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 "" + 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( + "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"))) + 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): + 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 From e496095cd11ee38a91b352a27f46826b21db3ab7 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 26 Jun 2026 02:20:11 +0000 Subject: [PATCH 03/19] docker: address review feedback on the JupyterLab/Studio UX - unsloth_nb_view.py: rebuilding the categorized view no longer deletes user files. The view is also JupyterLab's landing dir, so a user may save real notebooks there; _clear_view now unlinks only the symlinks we own and removes only folders that end up empty, leaving regular files in place. It also tests islink before isdir, so a view that is itself a symlink to a directory is unlinked instead of being walked into (which would have wiped the symlink target). - studio_launch.sh: derive the landing URL and preferred_dir from UNSLOTH_NOTEBOOKS_VIEW_DIR / UNSLOTH_SKIP_NOTEBOOK_VIEW, the same env the sync script uses, instead of hard-coding /workspace/Unsloth Notebooks. A relocated or disabled view no longer opens JupyterLab on a missing folder; it falls back to the default /lab over /workspace. - Dockerfile.studio: the labext-builder stage now installs Node 20 from NodeSource. Ubuntu 24.04's distro nodejs is 18, below JupyterLab 4.6's declared Node >=20 engine. Node stays confined to the throwaway builder stage, so the runtime image is unchanged. - .dockerignore: explicitly allowlist jupyter/install_sloth_stickers.py alongside its sibling jupyter assets, rather than relying on the directory re-inclusion. --- docker/.dockerignore | 1 + docker/Dockerfile.studio | 7 +++++- docker/studio_launch.sh | 27 +++++++++++++++------- docker/unsloth_nb_view.py | 48 +++++++++++++++++++++++++-------------- 4 files changed, 57 insertions(+), 26 deletions(-) diff --git a/docker/.dockerignore b/docker/.dockerignore index 02b68025317..edd5fe92349 100644 --- a/docker/.dockerignore +++ b/docker/.dockerignore @@ -22,6 +22,7 @@ !jupyter/favicon.ico !jupyter/logo.png !jupyter/login.html +!jupyter/install_sloth_stickers.py !jupyter/unsloth_labext !jupyter/unsloth_labext/package.json !jupyter/unsloth_labext/tsconfig.json diff --git a/docker/Dockerfile.studio b/docker/Dockerfile.studio index b2469b5188e..29ab702e8f3 100644 --- a/docker/Dockerfile.studio +++ b/docker/Dockerfile.studio @@ -37,8 +37,13 @@ ARG BASE_IMAGE=unsloth-blackwell:test # Node-free. Uses the base image's bundled jlpm + jupyterlab (version-matched). FROM ${BASE_IMAGE} AS labext-builder ENV DEBIAN_FRONTEND=noninteractive +# JupyterLab 4.6's build tooling declares a Node >=20 engine; Ubuntu 24.04's +# distro nodejs is 18, so pull Node 20 LTS from NodeSource (it bundles npm). +# This stage is thrown away, so the extra apt sources never reach the runtime. RUN apt-get update \ - && apt-get install -y --no-install-recommends nodejs npm git \ + && apt-get install -y --no-install-recommends ca-certificates curl gnupg git \ + && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* COPY jupyter/unsloth_labext /opt/labext-src RUN cd /opt/labext-src \ diff --git a/docker/studio_launch.sh b/docker/studio_launch.sh index 21be48bfd28..21a75a9a097 100644 --- a/docker/studio_launch.sh +++ b/docker/studio_launch.sh @@ -61,16 +61,27 @@ 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 + # Land straight in the categorized notebook view, but only when it is enabled + # AND lives under root_dir (so it is expressible as a /lab/tree path). Mirror + # unsloth_sync_notebooks.sh's UNSLOTH_NOTEBOOKS_VIEW_DIR / UNSLOTH_SKIP_NOTEBOOK_VIEW + # so a relocated or disabled view never points JupyterLab at a missing dir; + # in those cases JupyterLab just opens on its default (/lab) over /workspace. + _root_dir="/workspace" + _view_dir="${UNSLOTH_NOTEBOOKS_VIEW_DIR:-/workspace/Unsloth Notebooks}" + if [[ "${UNSLOTH_SKIP_NOTEBOOK_VIEW:-0}" != "1" && "${_view_dir}" == "${_root_dir}/"* ]]; then + _view_rel="${_view_dir#${_root_dir}/}" + # default_url must be set on BOTH ServerApp and LabApp -- the lab + # extension app otherwise overrides ServerApp's value back to /lab. + # preferred_dir points the file browser at that folder. A literal space + # is URL-encoded to %20 in the redirect itself. + cat >> "${JUPYTER_CONFIG_DIR}/jupyter_lab_config.py" < keep it for name in dirs: p = os.path.join(root, name) try: - (os.remove if os.path.islink(p) else os.rmdir)(p) + if os.path.islink(p): + os.remove(p) # symlinked dir: unlink, never recurse + else: + os.rmdir(p) # succeeds only if we emptied it except OSError: - pass - try: - os.rmdir(path) - except OSError: - pass + pass # holds user files -> keep + # Leave the VIEW root itself in place: it may still hold user files, and + # build_view recreates it right after anyway. def main(argv): From 50f7170be495abf6cd6c4b9a0cc48243e27d83db Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 02:23:03 +0000 Subject: [PATCH 04/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docker/unsloth_nb_view.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker/unsloth_nb_view.py b/docker/unsloth_nb_view.py index 3516e6b0581..9b8f35e3a37 100644 --- a/docker/unsloth_nb_view.py +++ b/docker/unsloth_nb_view.py @@ -182,7 +182,7 @@ def _clear_view(path): for root, dirs, files in os.walk(path, topdown = False): for name in files: p = os.path.join(root, name) - if os.path.islink(p): # our notebook symlinks only + if os.path.islink(p): # our notebook symlinks only try: os.remove(p) except OSError: @@ -192,11 +192,11 @@ def _clear_view(path): p = os.path.join(root, name) try: if os.path.islink(p): - os.remove(p) # symlinked dir: unlink, never recurse + os.remove(p) # symlinked dir: unlink, never recurse else: - os.rmdir(p) # succeeds only if we emptied it + os.rmdir(p) # succeeds only if we emptied it except OSError: - pass # holds user files -> keep + pass # holds user files -> keep # Leave the VIEW root itself in place: it may still hold user files, and # build_view recreates it right after anyway. From b963403b185e6802fc1c4c89a4048b2df094848d Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 26 Jun 2026 02:42:35 +0000 Subject: [PATCH 05/19] docker: publish lean image as :core and full image as :studio Complete the base->core (and "studio as studio") tag rename so the publish workflow matches the user-facing helpers and the Dockerfile.studio header. - The lean training image now publishes as :core (core-, core-nightly, core-sha-*); run.sh / docker_confirm.* already told users to pull :core, but docker-publish.yml still tagged it :base, so that pull would have 404'd. The per-arch digest artifacts are renamed to match. - The full Studio image keeps :latest and gains a stable :studio alias, matching the Dockerfile.studio header. Both the merge and post-publish smoke-test metadata blocks are updated together. Internal "base image" wording (the layer Studio builds FROM) is left as-is. --- .github/workflows/docker-publish.yml | 35 +++++++++++++++------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c5ce565e5e3..fe12735e404 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -181,7 +181,7 @@ jobs: - name: Upload digest uses: actions/upload-artifact@v4 with: - name: digests-base-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} + name: digests-core-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 @@ -208,7 +208,7 @@ jobs: - uses: actions/download-artifact@v4 with: path: /tmp/digests - pattern: digests-base-* + pattern: digests-core-* merge-multiple: true - uses: docker/setup-buildx-action@v3 @@ -225,17 +225,17 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - # The lean training image publishes under the base- prefix; the + # The lean training image publishes under the core- prefix; the # full Studio image (build-studio/merge-studio below) owns # :latest, matching what the previous production image shipped. - # Only tag :base when the workflow ran on the default branch + # Only tag :core when the workflow ran on the default branch # AND the operator did NOT override unsloth_ref on dispatch. # Without the second condition a maintainer testing a feature - # SHA from main could overwrite :base with non-main source. - type=raw,value=base,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }} - type=ref,event=tag,prefix=base- - type=schedule,pattern=base-nightly - type=sha,prefix=base-sha-,format=short + # SHA from main could overwrite :core with non-main source. + type=raw,value=core,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }} + type=ref,event=tag,prefix=core- + type=schedule,pattern=core-nightly + type=sha,prefix=core-sha-,format=short - name: Create multi-arch manifest working-directory: /tmp/digests @@ -380,8 +380,10 @@ jobs: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | # The full Studio image owns the unprefixed namespace, headed by - # :latest. Same :latest gating rationale as the base job. + # :latest, with a stable :studio alias. Same gating rationale as + # the core job. type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }} + type=raw,value=studio,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }} type=ref,event=tag type=schedule,pattern=nightly type=sha,prefix=sha-,format=short @@ -429,19 +431,19 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - type=raw,value=base,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }} - type=ref,event=tag,prefix=base- - type=schedule,pattern=base-nightly - type=sha,prefix=base-sha-,format=short + type=raw,value=core,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }} + type=ref,event=tag,prefix=core- + type=schedule,pattern=core-nightly + type=sha,prefix=core-sha-,format=short - name: Pull and smoke-test the base image run: | # Use the first tag from the metadata output -- that is the image we - # just published. Falls back to :base only when the metadata is + # just published. Falls back to :core only when the metadata is # empty (defensive; should not happen on default-branch runs). TAG="$(jq -r '.tags[0] // ""' <<<"$STEPS_META_BASE_JSON")" if [ -z "$TAG" ]; then - TAG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:base" + TAG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:core" fi echo "smoke-testing $TAG" docker pull "$TAG" @@ -456,6 +458,7 @@ jobs: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }} + type=raw,value=studio,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }} type=ref,event=tag type=schedule,pattern=nightly type=sha,prefix=sha-,format=short From dde7a26483b039d22271de5b4c9430f5093cf951 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 26 Jun 2026 05:35:13 +0000 Subject: [PATCH 06/19] docker: address second-round review feedback on the JupyterLab/Studio UX - studio_launch.sh: also gate the categorized-view landing URL on UNSLOTH_SKIP_NOTEBOOK_SYNC (the entrypoint skips building the view entirely in that mode), not just UNSLOTH_SKIP_NOTEBOOK_VIEW, so a no-sync container does not land on a missing folder. - Dockerfile.studio: scope the sticker-install "|| echo" fallback to only the sticker step via a { ...; } group. It was attached to the whole branding && chain, so a failure in a REQUIRED step (JS resolve, favicon/logo/login copy) was swallowed and the build continued with broken branding. - unsloth_nb_view.py: when creating the categorized symlinks, only replace our own stale symlinks; if a real user file already occupies that name, keep it and skip the link instead of os.remove-ing it. - overrides.json: drop doNotDisturbMode (it silenced ALL JupyterLab toasts, including kernel-restart / connection-drop feedback). The news/update prompts are already off via fetchNews / checkForUpdates. - Dockerfile: keep decord mandatory on amd64 (fail the build on a missing or incompatible wheel) and only fail-soft on arm64/other arches that have no wheel. - cellNav.ts: do not hijack ArrowUp/Down when focus is in an interactive output widget / form control, or while a completion popup is open, so ipywidgets controls and autocomplete at cell boundaries keep working. --- docker/Dockerfile | 15 ++++++++----- docker/Dockerfile.studio | 11 ++++++---- docker/jupyter/overrides.json | 3 +-- docker/jupyter/unsloth_labext/src/cellNav.ts | 23 ++++++++++++++++++++ docker/studio_launch.sh | 12 ++++++---- docker/unsloth_nb_view.py | 10 +++++++-- 6 files changed, 57 insertions(+), 17 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5ea94b11344..a45bc04942e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -303,11 +303,16 @@ RUN ${VENV}/bin/uv pip install \ "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" +# decord (ERNIE-VL video decode) publishes wheels only for x86_64 / win_amd64. +# Install it on its own: HARD on amd64 (a missing/incompatible wheel is a real +# regression there and must fail the build), fail-soft on arm64/other (no wheel +# exists, so drop the ERNIE-VL video path rather than break the image build). +RUN if [ "${TARGETARCH:-amd64}" = "amd64" ]; then \ + ${VENV}/bin/uv pip install --python ${VENV}/bin/python "decord==0.6.0"; \ + else \ + ${VENV}/bin/uv pip install --python ${VENV}/bin/python "decord==0.6.0" \ + || echo ">> decord skipped (no matching wheel for ${TARGETARCH:-}); ERNIE-VL video decode unavailable"; \ + fi # Audio decode out of the box: the TTS/STT notebooks feed datasets' Audio # features, which decode through torchcodec. Three traps, all defended: diff --git a/docker/Dockerfile.studio b/docker/Dockerfile.studio index 29ab702e8f3..4b88c73ca26 100644 --- a/docker/Dockerfile.studio +++ b/docker/Dockerfile.studio @@ -220,12 +220,15 @@ RUN JS="$(/opt/unsloth-venv/bin/python -c 'import os, jupyter_server; print(os.p && 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 \ + # jupyter_server's static dir (sloth/NN.png). Fail-soft -- but scoped to ONLY + # the sticker step via the { ...; } group so a failure in a REQUIRED branding + # step above (JS resolve, favicon/logo/login copy) still fails the build + # instead of being swallowed by this fallback. 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)" \ + || 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 diff --git a/docker/jupyter/overrides.json b/docker/jupyter/overrides.json index 6ffc6704d82..4fe9590fdbd 100644 --- a/docker/jupyter/overrides.json +++ b/docker/jupyter/overrides.json @@ -35,7 +35,6 @@ }, "@jupyterlab/apputils-extension:notification": { "fetchNews": "false", - "checkForUpdates": false, - "doNotDisturbMode": true + "checkForUpdates": false } } diff --git a/docker/jupyter/unsloth_labext/src/cellNav.ts b/docker/jupyter/unsloth_labext/src/cellNav.ts index 81eb9d840c1..a2a84b204d4 100644 --- a/docker/jupyter/unsloth_labext/src/cellNav.ts +++ b/docker/jupyter/unsloth_labext/src/cellNav.ts @@ -41,6 +41,19 @@ const cellNavPlugin: JupyterFrontEndPlugin = { if (!panel.node.contains(event.target as Node)) { return; } + // Never hijack arrows that belong to an interactive output (an ipywidgets + // slider / dropdown / text box created by a cell) or a plain form control; + // only the cell editor and the notebook's own command-mode cell nav. + const targetEl = event.target as HTMLElement | null; + if (targetEl) { + if (targetEl.closest('.jp-OutputArea')) { + return; + } + const tag = targetEl.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { + return; + } + } const notebook = panel.content; const direction = event.key === 'ArrowDown' ? 1 : -1; const editing = notebook.mode === 'edit'; @@ -49,6 +62,16 @@ const cellNavPlugin: JupyterFrontEndPlugin = { if (!editor) { return; } + // While a completion / autocomplete popup is open, the arrows belong to + // it (moving through the suggestions) -- do not take over even at a cell + // boundary, which is common in one-line setup cells. + if ( + document.querySelector( + '.jp-Completer:not(.lm-mod-hidden), .cm-tooltip-autocomplete' + ) + ) { + 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). diff --git a/docker/studio_launch.sh b/docker/studio_launch.sh index 21a75a9a097..5ffffa1e4c4 100644 --- a/docker/studio_launch.sh +++ b/docker/studio_launch.sh @@ -65,12 +65,16 @@ c.PasswordIdentityProvider.hashed_password = "${HASH}" EOF # Land straight in the categorized notebook view, but only when it is enabled # AND lives under root_dir (so it is expressible as a /lab/tree path). Mirror - # unsloth_sync_notebooks.sh's UNSLOTH_NOTEBOOKS_VIEW_DIR / UNSLOTH_SKIP_NOTEBOOK_VIEW - # so a relocated or disabled view never points JupyterLab at a missing dir; - # in those cases JupyterLab just opens on its default (/lab) over /workspace. + # unsloth_sync_notebooks.sh's gating -- UNSLOTH_NOTEBOOKS_VIEW_DIR plus both + # UNSLOTH_SKIP_NOTEBOOK_VIEW (no view built) and UNSLOTH_SKIP_NOTEBOOK_SYNC + # (entrypoint skips sync entirely, so nothing under the view dir exists) -- so + # a relocated, disabled, or unsynced view never points JupyterLab at a missing + # dir; in those cases JupyterLab just opens on its default (/lab) over /workspace. _root_dir="/workspace" _view_dir="${UNSLOTH_NOTEBOOKS_VIEW_DIR:-/workspace/Unsloth Notebooks}" - if [[ "${UNSLOTH_SKIP_NOTEBOOK_VIEW:-0}" != "1" && "${_view_dir}" == "${_root_dir}/"* ]]; then + if [[ "${UNSLOTH_SKIP_NOTEBOOK_VIEW:-0}" != "1" \ + && "${UNSLOTH_SKIP_NOTEBOOK_SYNC:-0}" != "1" \ + && "${_view_dir}" == "${_root_dir}/"* ]]; then _view_rel="${_view_dir#${_root_dir}/}" # default_url must be set on BOTH ServerApp and LabApp -- the lab # extension app otherwise overrides ServerApp's value back to /lab. diff --git a/docker/unsloth_nb_view.py b/docker/unsloth_nb_view.py index 9b8f35e3a37..6eb5655d5f9 100644 --- a/docker/unsloth_nb_view.py +++ b/docker/unsloth_nb_view.py @@ -155,8 +155,14 @@ def allowed(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) + if os.path.islink(link): + os.remove(link) # replace our own stale symlink + elif os.path.exists(link): + # a real user file/dir already occupies this name -- never + # clobber it; leave it and skip linking this notebook. + print(f"[unsloth-nb] view: keep user file, skip link {fname}", + file = sys.stderr) + continue os.symlink(rel, link) n_links += 1 except OSError as e: From 2eba8a49763cdb330ae69b53cf4ab0a63a1565c3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:35:38 +0000 Subject: [PATCH 07/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docker/unsloth_nb_view.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docker/unsloth_nb_view.py b/docker/unsloth_nb_view.py index 6eb5655d5f9..9c74081999e 100644 --- a/docker/unsloth_nb_view.py +++ b/docker/unsloth_nb_view.py @@ -156,12 +156,11 @@ def allowed(fname): rel = os.path.relpath(target, folder) # ../../unsloth-notebooks/nb/ try: if os.path.islink(link): - os.remove(link) # replace our own stale symlink + os.remove(link) # replace our own stale symlink elif os.path.exists(link): # a real user file/dir already occupies this name -- never # clobber it; leave it and skip linking this notebook. - print(f"[unsloth-nb] view: keep user file, skip link {fname}", - file = sys.stderr) + print(f"[unsloth-nb] view: keep user file, skip link {fname}", file = sys.stderr) continue os.symlink(rel, link) n_links += 1 From 9a53256375b08a15ee9394d91bb7469f022f151a Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 26 Jun 2026 08:24:39 +0000 Subject: [PATCH 08/19] docker: keep Studio branding RUN free of comments inside the line continuation Move the sloth-sticker fail-soft explanation above the RUN so no comment line sits between backslash-continued commands. BuildKit strips such comments, but keeping the RUN body a plain && chain removes the ambiguity for non-BuildKit builders and static linters. The { ...; } fail-soft scoping is unchanged. --- docker/Dockerfile.studio | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docker/Dockerfile.studio b/docker/Dockerfile.studio index 4b88c73ca26..59b6fa0eee1 100644 --- a/docker/Dockerfile.studio +++ b/docker/Dockerfile.studio @@ -209,6 +209,13 @@ COPY --from=labext-builder /opt/labext-src/unsloth-jupyterlab/labextension /opt/ # 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). +# The sloth-sticker install is the ONLY fail-soft branding step: it is scoped to +# its own { ...; } group with a `|| echo` fallback below, so a missing Studio +# "Sloth emojis" folder does not break the build, while the REQUIRED steps above +# it (JS resolve, favicon/logo/login copy) stay fatal. (The comment is kept out +# of the RUN body so no comment line sits inside a backslash continuation, which +# some Dockerfile parsers choke on.) login.html's onerror falls back to the +# Unsloth logo if the sticker dir is ever absent. 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 @@ -219,12 +226,6 @@ RUN JS="$(/opt/unsloth-venv/bin/python -c 'import os, jupyter_server; print(os.p 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 -- but scoped to ONLY - # the sticker step via the { ...; } group so a failure in a REQUIRED branding - # step above (JS resolve, favicon/logo/login copy) still fails the build - # instead of being swallowed by this fallback. 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" \ From 6fd9744f3174a0a0ee1bb881f2c3049d725bf50d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:42:41 +0000 Subject: [PATCH 09/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docker/unsloth_pip_shim.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docker/unsloth_pip_shim.py b/docker/unsloth_pip_shim.py index efdd9a5bf34..080d071ba57 100644 --- a/docker/unsloth_pip_shim.py +++ b/docker/unsloth_pip_shim.py @@ -88,7 +88,10 @@ def _canon(token): # protected package pinned through a URL slips past _KEEP and reinstalls into # the base venv. A non-protected direct reference still returns its name and # is kept by the caller exactly as before (treated as an install target). - _dref = re.match(r"^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*@(?:\s|git\+|hg\+|bzr\+|svn\+|[a-z]+://)", token) + _dref = re.match( + r"^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*@(?:\s|git\+|hg\+|bzr\+|svn\+|[a-z]+://)", + token, + ) if _dref: return _dref.group(1).lower().replace("_", "-") or None if re.match(r"^[a-z]+\+", token) or "://" in token or token.startswith((".", "/")): From 3fb8804854c31adb8baf219455ca870b8edc391f Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 27 Jun 2026 07:43:35 +0000 Subject: [PATCH 10/19] docker: AGPLv3 attribution + integrity guard for the Studio/JupyterLab image Make it obvious the image is built by Unsloth and hard to white-label out with a shallow find-and-replace, and surface the AGPLv3 license + copyright in the UI. Visible attribution (labextension): - Help > "About Unsloth Docker Studio" dialog (about.ts): Unsloth logo, the AGPLv3 notice, "Copyright 2026-Present the Unsloth team", and source/website/ license links. Added to the Help menu and the command palette. - The JupyterLab loading splash is replaced with a spinning Unsloth logo (splash.ts, provides ISplashScreen; honors prefers-reduced-motion). The stock @jupyterlab/apputils-extension:splash is disabled+locked at build time, like the stock logo. - AGPLv3 footer (license + copyright + links) on the branded login page. - Labextension relicensed AGPL-3.0-only; SPDX headers on every source file. Anti-tamper (no encoded/obfuscated strings -- plain readable text only; the one data URI is the logo image): - A canonical, plain-text attribution set lives in unsloth_branding.py with a TypeScript mirror (branding.ts) bundled verbatim into the labextension, so the phrase, copyright, links and plugin ids are spread across independent layers. - unsloth_branding.py verifies all of these across the installed files (AGPLv3 text, login footer, theme, labextension package + built bundle strings, logo, favicon) and fails loudly if any are missing. It runs at three layers: build time (fails the image build), the whole-container launcher (studio_launch.sh refuses to start), and as a jupyter_server extension (refuses to serve JupyterLab). - tests/studio/test_branding_guard.py: positive + per-marker negative coverage, plus a check that no base64/decoder obfuscation crept into the attribution. --- docker/.dockerignore | 3 + docker/Dockerfile.studio | 22 +- .../unsloth_branding_guard.json | 7 + docker/jupyter/login.html | 15 ++ docker/jupyter/unsloth_branding.py | 252 ++++++++++++++++++ docker/jupyter/unsloth_labext/package.json | 4 +- docker/jupyter/unsloth_labext/src/about.ts | 86 ++++++ docker/jupyter/unsloth_labext/src/branding.ts | 24 ++ docker/jupyter/unsloth_labext/src/cellNav.ts | 3 + .../jupyter/unsloth_labext/src/colabTitle.ts | 3 + docker/jupyter/unsloth_labext/src/index.ts | 9 +- docker/jupyter/unsloth_labext/src/logo.ts | 3 + .../unsloth_labext/src/outputSelect.ts | 3 + docker/jupyter/unsloth_labext/src/splash.ts | 89 +++++++ docker/jupyter/unsloth_labext/src/uiChrome.ts | 3 + docker/studio_launch.sh | 15 ++ tests/studio/test_branding_guard.py | 199 ++++++++++++++ 17 files changed, 737 insertions(+), 3 deletions(-) create mode 100644 docker/jupyter/jupyter_server_config.d/unsloth_branding_guard.json create mode 100644 docker/jupyter/unsloth_branding.py create mode 100644 docker/jupyter/unsloth_labext/src/about.ts create mode 100644 docker/jupyter/unsloth_labext/src/branding.ts create mode 100644 docker/jupyter/unsloth_labext/src/splash.ts create mode 100644 tests/studio/test_branding_guard.py diff --git a/docker/.dockerignore b/docker/.dockerignore index edd5fe92349..58b1964976e 100644 --- a/docker/.dockerignore +++ b/docker/.dockerignore @@ -18,6 +18,9 @@ !unsloth_nb_strip_colab.py !unsloth_colab_compat.py !jupyter +!jupyter/unsloth_branding.py +!jupyter/jupyter_server_config.d +!jupyter/jupyter_server_config.d/** !jupyter/overrides.json !jupyter/favicon.ico !jupyter/logo.png diff --git a/docker/Dockerfile.studio b/docker/Dockerfile.studio index 7f9edbd7691..d0f8cfb510e 100644 --- a/docker/Dockerfile.studio +++ b/docker/Dockerfile.studio @@ -238,7 +238,27 @@ RUN JS="$(/opt/unsloth-venv/bin/python -c 'import os, jupyter_server; print(os.p || 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 + && /opt/unsloth-venv/bin/jupyter labextension lock @jupyterlab/application-extension:logo \ + && /opt/unsloth-venv/bin/jupyter labextension disable @jupyterlab/apputils-extension:splash \ + && /opt/unsloth-venv/bin/jupyter labextension lock @jupyterlab/apputils-extension:splash +# Branding integrity guard: the canonical attribution checker (also a +# jupyter_server extension), the full AGPLv3 license text, and the config that +# enables the extension. Installed into the base venv so it is on the jupyter +# process's import + config search path. The stock @apputils-extension:splash is +# disabled+locked above so the labextension's spinning-logo splash is the sole +# ISplashScreen provider. The build-time --verify FAILS the image build if any +# Unsloth attribution / license asset is missing or altered. +COPY jupyter/unsloth_branding.py /tmp/unsloth-branding-guard/unsloth_branding.py +COPY jupyter/jupyter_server_config.d/unsloth_branding_guard.json /tmp/unsloth-branding-guard/unsloth_branding_guard.json +RUN SP="$(/opt/unsloth-venv/bin/python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')" \ + && cp /tmp/unsloth-branding-guard/unsloth_branding.py "${SP}/unsloth_branding.py" \ + && mkdir -p /opt/unsloth-venv/etc/jupyter/jupyter_server_config.d \ + && cp /tmp/unsloth-branding-guard/unsloth_branding_guard.json \ + /opt/unsloth-venv/etc/jupyter/jupyter_server_config.d/unsloth_branding_guard.json \ + && cp "${UNSLOTH_STUDIO_HOME}/src/studio/LICENSE.AGPL-3.0" \ + /opt/unsloth-venv/share/jupyter/UNSLOTH_LICENSE.AGPL-3.0 \ + && rm -rf /tmp/unsloth-branding-guard \ + && /opt/unsloth-venv/bin/python -m unsloth_branding --verify 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/jupyter/jupyter_server_config.d/unsloth_branding_guard.json b/docker/jupyter/jupyter_server_config.d/unsloth_branding_guard.json new file mode 100644 index 00000000000..592d6ad6de6 --- /dev/null +++ b/docker/jupyter/jupyter_server_config.d/unsloth_branding_guard.json @@ -0,0 +1,7 @@ +{ + "ServerApp": { + "jpserver_extensions": { + "unsloth_branding": true + } + } +} diff --git a/docker/jupyter/login.html b/docker/jupyter/login.html index 1ff4d23df4c..a81b3447f2b 100644 --- a/docker/jupyter/login.html +++ b/docker/jupyter/login.html @@ -57,6 +57,14 @@ .unsloth-login-card button:hover { background: hsl(160, 55%, 36%); } .unsloth-login-card .message { margin-top: 16px; font-size: 13px; } .unsloth-login-card .message.error { color: hsl(0, 75%, 68%); } + /* AGPLv3 attribution footer. Part of the Unsloth attribution set the image's + integrity guard verifies (Built by Unsloth + copyright + source link). */ + .unsloth-attrib { + margin-top: 18px; text-align: center; font-size: 12px; line-height: 1.6; + color: hsl(60, 8%, 58%); width: 360px; max-width: 90vw; + } + .unsloth-attrib a { color: hsl(160, 45%, 60%); text-decoration: none; } + .unsloth-attrib a:hover { text-decoration: underline; } {% endblock %} @@ -92,6 +100,13 @@

Unsloth

{% endfor %} {% endif %} +
+ Built by Unsloth. Licensed under the GNU AGPLv3.
+ Copyright 2026-Present the Unsloth team.
+ github.com/unslothai/unsloth + · + unsloth.ai +
{% endblock %} {% block script %}{% endblock %} diff --git a/docker/jupyter/unsloth_branding.py b/docker/jupyter/unsloth_branding.py new file mode 100644 index 00000000000..44722dee7c4 --- /dev/null +++ b/docker/jupyter/unsloth_branding.py @@ -0,0 +1,252 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0 +"""Unsloth Docker Studio branding + AGPLv3 attribution integrity guard. + +This image is built by Unsloth and is licensed under the GNU AGPLv3. The +attribution (the Unsloth logo + theme, the Help > About dialog, the spinning +splash, the AGPLv3 notice and the source/website links) is shipped across +several independent files on purpose, so a reseller cannot white-label the image +with a shallow find-and-replace. This module is the canonical, plain-text source +of truth for those strings AND the checker that verifies they are still present. + +Everything here is plain readable text -- there are no base64/encoded/obfuscated +copies of the attribution (those would trip antivirus scanners and are pointless +for an open-source image). The single base64 blob in the build is the logo +*image* data URI in the labextension, which is an image, not hidden text. + +The guard runs in three places (see docker/Dockerfile.studio, docker/studio_launch.sh): + * build time -- `python -m unsloth_branding --verify` fails the image build + if any attribution asset is missing or altered. + * whole image -- studio_launch.sh runs the same check before launching + supervisord; a failure refuses to start the container. + * JupyterLab -- this module is also a jupyter_server extension; on load it + re-checks and refuses to serve JupyterLab if attribution was + stripped after the container started. +""" + +import json +import os +import sys + +# --------------------------------------------------------------------------- +# Canonical attribution strings. Plain text. Keep in sync with the TypeScript +# mirror at unsloth_labext/src/branding.ts (the guard checks the built bundle +# contains these same strings). +# --------------------------------------------------------------------------- +PRODUCT = "Unsloth Docker Studio" +SHORT_LABEL = "Built by Unsloth" +COPYRIGHT = "Copyright 2026-Present the Unsloth team" +AGPL_NOTICE = "Licensed under the GNU AGPLv3" +WEBSITE_URL = "https://unsloth.ai" +SOURCE_URL = "https://github.com/unslothai/unsloth" +AGPL_URL = "https://www.gnu.org/licenses/agpl-3.0.html" +# ONE plain literal, byte-identical to PHRASE in unsloth_labext/src/branding.ts. +# The guard greps the built labext bundle for this exact string, so it must match +# the TS literal verbatim (webpack keeps single string literals as-is). +PHRASE = ( + "Unsloth Docker Studio and JupyterLab image. Built by Unsloth. " + "Licensed under the GNU AGPLv3. " + "Source: https://github.com/unslothai/unsloth Website: https://unsloth.ai" +) + +THEME_NAME = "Unsloth Dark" +LABEXT_NAME = "unsloth-jupyterlab" +ABOUT_PLUGIN_ID = "unsloth-jupyterlab:about" +SPLASH_PLUGIN_ID = "unsloth-jupyterlab:splash" +# Prefix of the embedded logo image data URI in unsloth_labext/src/logo.ts. +# Removing the logo (a load-bearing ~19KB literal) breaks the top bar + splash. +LOGO_DATA_URI_PREFIX = "data:image/png;base64,iVBOR" + + +def resolve_paths(venv_share=None, jupyter_server_dir=None): + """Resolve the installed locations of every checked branding asset. + + Defaults point at the live venv + the installed jupyter_server package. Tests + pass explicit roots so the checker can run against a staged temp tree. + """ + if venv_share is None: + venv_share = os.path.join(sys.prefix, "share", "jupyter") + if jupyter_server_dir is None: + import jupyter_server # local import: only needed for live resolution + + jupyter_server_dir = os.path.dirname(jupyter_server.__file__) + labext_dir = os.path.join(venv_share, "labextensions", LABEXT_NAME) + return { + "license": os.path.join(venv_share, "UNSLOTH_LICENSE.AGPL-3.0"), + "login": os.path.join(jupyter_server_dir, "templates", "login.html"), + "overrides": os.path.join(venv_share, "lab", "settings", "overrides.json"), + "labext_dir": labext_dir, + "labext_pkg": os.path.join(labext_dir, "package.json"), + "labext_static": os.path.join(labext_dir, "static"), + "favicon": os.path.join(jupyter_server_dir, "static", "favicons", "favicon.ico"), + "logo": os.path.join(jupyter_server_dir, "static", "logo", "logo.png"), + } + + +def _read(path): + try: + with open(path, encoding = "utf-8", errors = "replace") as f: + return f.read() + except OSError: + return None + + +def _nonempty_file(path): + try: + return os.path.getsize(path) > 0 + except OSError: + return False + + +def _bundle_text(static_dir): + """Concatenate every built .js chunk under the labextension static dir. + + The webpack production build splits the extension into several chunks but + keeps string literals verbatim (only identifiers are minified), so the + canonical attribution strings appear in one of these files. + """ + if not os.path.isdir(static_dir): + return "" + parts = [] + for name in sorted(os.listdir(static_dir)): + if name.endswith(".js"): + text = _read(os.path.join(static_dir, name)) + if text: + parts.append(text) + return "\n".join(parts) + + +def verify_branding(paths = None): + """Return a list of human-readable problems; empty list means all good.""" + if paths is None: + paths = resolve_paths() + problems = [] + + # 1. Full AGPLv3 license text shipped in the image. + license_text = _read(paths["license"]) + if license_text is None: + problems.append("missing AGPLv3 license file: " + paths["license"]) + elif "GNU AFFERO GENERAL PUBLIC LICENSE" not in license_text or "Version 3" not in license_text: + problems.append("AGPLv3 license file is not the GNU AGPL v3 text: " + paths["license"]) + + # 2. Branded login page carries the attribution + copyright + source link. + login = _read(paths["login"]) + if login is None: + problems.append("missing branded login page: " + paths["login"]) + else: + for marker in (SHORT_LABEL, COPYRIGHT, SOURCE_URL, "AGPLv3"): + if marker not in login: + problems.append("login page missing attribution marker: " + marker) + + # 3. The Unsloth Dark theme is the configured default. + overrides = _read(paths["overrides"]) + if not overrides or THEME_NAME not in overrides: + problems.append("overrides.json missing the '" + THEME_NAME + "' theme") + + # 4. The prebuilt labextension is installed and is ours. + pkg = _read(paths["labext_pkg"]) + if pkg is None: + problems.append("missing labextension: " + paths["labext_pkg"]) + else: + try: + if json.loads(pkg).get("name") != LABEXT_NAME: + problems.append("labextension package.json name is not " + LABEXT_NAME) + except ValueError: + problems.append("labextension package.json is not valid JSON") + + # 5. The built bundle still carries the visible attribution strings + plugins. + bundle = _bundle_text(paths["labext_static"]) + if not bundle: + problems.append("missing built labextension bundle: " + paths["labext_static"]) + else: + for marker in ( + PHRASE, + SHORT_LABEL, + COPYRIGHT, + AGPL_URL, + ABOUT_PLUGIN_ID, + SPLASH_PLUGIN_ID, + LOGO_DATA_URI_PREFIX, + ): + if marker not in bundle: + problems.append("labextension bundle missing: " + marker) + + # 6. Favicon + logo images present and non-empty. + if not _nonempty_file(paths["favicon"]): + problems.append("missing or empty favicon: " + paths["favicon"]) + if not _nonempty_file(paths["logo"]): + problems.append("missing or empty logo: " + paths["logo"]) + + return problems + + +def banner(problems): + """A loud, plain-text failure banner naming what was stripped.""" + lines = [ + "", + "=" * 72, + "ERROR: Unsloth Docker Studio attribution / license integrity check failed.", + "", + "This image is built by Unsloth and ships under the GNU AGPLv3. It will not", + "start because required attribution or license assets are missing or altered:", + "", + ] + for p in problems: + lines.append(" - " + p) + lines += [ + "", + SHORT_LABEL + ". " + COPYRIGHT + ".", + "Website: " + WEBSITE_URL, + "Source: " + SOURCE_URL, + "License: GNU AGPLv3 (" + AGPL_URL + ")", + "=" * 72, + "", + ] + return "\n".join(lines) + + +# --- jupyter_server extension (Layer B: refuse to serve JupyterLab) ---------- +def _jupyter_server_extension_points(): + return [{"module": "unsloth_branding"}] + + +def _load_jupyter_server_extension(serverapp): + problems = verify_branding() + if not problems: + return + msg = banner(problems) + print(msg, file = sys.stderr, flush = True) + try: + serverapp.log.critical(msg) + except Exception: + pass + # Stop the server cleanly, then guarantee exit if that is swallowed during + # extension load. studio_launch.sh (Layer A) normally refuses the whole + # container first; this is defense in depth for a direct `jupyter lab` run. + try: + serverapp.exit(1) + except Exception: + pass + raise SystemExit(1) + + +def main(argv = None): + import argparse + + parser = argparse.ArgumentParser(description = "Unsloth branding integrity check") + parser.add_argument("--verify", action = "store_true", help = "verify and exit nonzero on failure") + parser.add_argument("--venv-share", default = None) + parser.add_argument("--jupyter-server-dir", default = None) + args = parser.parse_args(argv) + + paths = resolve_paths(args.venv_share, args.jupyter_server_dir) + problems = verify_branding(paths) + if problems: + print(banner(problems), file = sys.stderr, flush = True) + return 1 + print("Unsloth branding integrity check passed (" + PRODUCT + ", AGPLv3).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docker/jupyter/unsloth_labext/package.json b/docker/jupyter/unsloth_labext/package.json index 2f53c6853fb..80284ed2bb1 100644 --- a/docker/jupyter/unsloth_labext/package.json +++ b/docker/jupyter/unsloth_labext/package.json @@ -8,7 +8,7 @@ "jupyterlab-extension", "theme" ], - "license": "Apache-2.0", + "license": "AGPL-3.0-only", "author": "Unsloth AI", "private": true, "main": "lib/index.js", @@ -35,8 +35,10 @@ "@jupyterlab/apputils": "^4.5.0", "@jupyterlab/cells": "^4.5.0", "@jupyterlab/codemirror": "^4.5.0", + "@jupyterlab/mainmenu": "^4.5.0", "@jupyterlab/notebook": "^4.5.0", "@jupyterlab/theme-dark-extension": "^4.5.0", + "@lumino/disposable": "^2.0.0", "@lumino/widgets": "^2.0.0" }, "devDependencies": { diff --git a/docker/jupyter/unsloth_labext/src/about.ts b/docker/jupyter/unsloth_labext/src/about.ts new file mode 100644 index 00000000000..f4bc8b306ef --- /dev/null +++ b/docker/jupyter/unsloth_labext/src/about.ts @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0 +// +// "About Unsloth Docker Studio" command -> Help menu + command palette. Surfaces +// the AGPLv3 license, the copyright line and the Unsloth source/website links so +// the image's provenance is one click away inside JupyterLab. + +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import { Dialog, ICommandPalette, showDialog } from '@jupyterlab/apputils'; +import { IMainMenu } from '@jupyterlab/mainmenu'; +import { Widget } from '@lumino/widgets'; +import { UNSLOTH_LOGO_DATA_URI } from './logo'; +import { + AGPL_NOTICE, + AGPL_URL, + COPYRIGHT, + PHRASE, + PRODUCT, + SHORT_LABEL, + SOURCE_URL, + WEBSITE_URL +} from './branding'; + +const COMMAND_ID = 'unsloth:about'; + +/** + * Build the About dialog body. The content is composed only from the trusted + * constants in branding.ts (no user input), so the static innerHTML carries no + * injection surface. PHRASE is stamped as a data attribute so the canonical + * attribution string is bundled verbatim for the integrity guard to find. + */ +function aboutBody(): Widget { + const body = new Widget(); + const el = body.node; + el.style.textAlign = 'center'; + el.style.padding = '4px 10px 10px'; + el.style.maxWidth = '430px'; + el.setAttribute('data-unsloth-attribution', PHRASE); + el.innerHTML = ` + Unsloth +
${PRODUCT}
+
${SHORT_LABEL}
+
+ ${AGPL_NOTICE}.
+ Source: ${SOURCE_URL}
+ Website: ${WEBSITE_URL}
+ License: GNU AGPLv3 +
+
${COPYRIGHT}
+ `; + return body; +} + +const aboutPlugin: JupyterFrontEndPlugin = { + id: 'unsloth-jupyterlab:about', + description: 'About Unsloth Docker Studio (AGPLv3 attribution).', + autoStart: true, + optional: [IMainMenu, ICommandPalette], + activate: ( + app: JupyterFrontEnd, + mainMenu: IMainMenu | null, + palette: ICommandPalette | null + ): void => { + app.commands.addCommand(COMMAND_ID, { + label: 'About ' + PRODUCT, + execute: () => + showDialog({ + title: 'About ' + PRODUCT, + body: aboutBody(), + buttons: [Dialog.okButton({ label: 'Close' })] + }) + }); + if (mainMenu) { + mainMenu.helpMenu.addGroup([{ command: COMMAND_ID }], 20); + } + if (palette) { + palette.addItem({ command: COMMAND_ID, category: 'Help' }); + } + } +}; + +export default aboutPlugin; diff --git a/docker/jupyter/unsloth_labext/src/branding.ts b/docker/jupyter/unsloth_labext/src/branding.ts new file mode 100644 index 00000000000..9f1635714cf --- /dev/null +++ b/docker/jupyter/unsloth_labext/src/branding.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0 +// +// Canonical attribution strings for the Unsloth Docker Studio image, mirrored +// from docker/jupyter/unsloth_branding.py. These are imported by the About and +// splash plugins so they are bundled verbatim into the built labextension; the +// Python integrity guard checks the built bundle still contains them. Plain +// readable text only -- never base64/encoded (that would trip antivirus and is +// pointless for an open-source image). + +export const PRODUCT = 'Unsloth Docker Studio'; +export const SHORT_LABEL = 'Built by Unsloth'; +export const COPYRIGHT = 'Copyright 2026-Present the Unsloth team'; +export const AGPL_NOTICE = 'Licensed under the GNU AGPLv3'; +export const WEBSITE_URL = 'https://unsloth.ai'; +export const SOURCE_URL = 'https://github.com/unslothai/unsloth'; +export const AGPL_URL = 'https://www.gnu.org/licenses/agpl-3.0.html'; + +// Must equal PHRASE in unsloth_branding.py (the guard greps the built bundle for +// it). Kept as ONE plain literal -- not a concatenation of the constants above -- +// so webpack/terser preserves the full phrase contiguously in the bundle instead +// of folding it into a runtime `+` expression the guard could not grep for. +export const PHRASE = + 'Unsloth Docker Studio and JupyterLab image. Built by Unsloth. Licensed under the GNU AGPLv3. Source: https://github.com/unslothai/unsloth Website: https://unsloth.ai'; diff --git a/docker/jupyter/unsloth_labext/src/cellNav.ts b/docker/jupyter/unsloth_labext/src/cellNav.ts index a2a84b204d4..cd02c1dff6a 100644 --- a/docker/jupyter/unsloth_labext/src/cellNav.ts +++ b/docker/jupyter/unsloth_labext/src/cellNav.ts @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0 + import { JupyterFrontEnd, JupyterFrontEndPlugin diff --git a/docker/jupyter/unsloth_labext/src/colabTitle.ts b/docker/jupyter/unsloth_labext/src/colabTitle.ts index 39f3f9f9868..6a11db7cabd 100644 --- a/docker/jupyter/unsloth_labext/src/colabTitle.ts +++ b/docker/jupyter/unsloth_labext/src/colabTitle.ts @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0 + import { JupyterFrontEnd, JupyterFrontEndPlugin diff --git a/docker/jupyter/unsloth_labext/src/index.ts b/docker/jupyter/unsloth_labext/src/index.ts index d2838a50472..91b22745523 100644 --- a/docker/jupyter/unsloth_labext/src/index.ts +++ b/docker/jupyter/unsloth_labext/src/index.ts @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0 + import { ILabShell, JupyterFrontEnd, @@ -6,9 +9,11 @@ import { import { IThemeManager } from '@jupyterlab/apputils'; import { Widget } from '@lumino/widgets'; import { UNSLOTH_LOGO_DATA_URI } from './logo'; +import aboutPlugin from './about'; import cellNavPlugin from './cellNav'; import colabTitlePlugin from './colabTitle'; import outputSelectPlugin from './outputSelect'; +import splashPlugin from './splash'; import uiChromePlugin from './uiChrome'; /** @@ -70,5 +75,7 @@ export default [ logoPlugin, colabTitlePlugin, outputSelectPlugin, - uiChromePlugin + uiChromePlugin, + aboutPlugin, + splashPlugin ]; diff --git a/docker/jupyter/unsloth_labext/src/logo.ts b/docker/jupyter/unsloth_labext/src/logo.ts index 76c64e97e1f..a4617a3b5e7 100644 --- a/docker/jupyter/unsloth_labext/src/logo.ts +++ b/docker/jupyter/unsloth_labext/src/logo.ts @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0 + // 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. diff --git a/docker/jupyter/unsloth_labext/src/outputSelect.ts b/docker/jupyter/unsloth_labext/src/outputSelect.ts index a49db67d530..a1641a967f4 100644 --- a/docker/jupyter/unsloth_labext/src/outputSelect.ts +++ b/docker/jupyter/unsloth_labext/src/outputSelect.ts @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0 + import { JupyterFrontEnd, JupyterFrontEndPlugin diff --git a/docker/jupyter/unsloth_labext/src/splash.ts b/docker/jupyter/unsloth_labext/src/splash.ts new file mode 100644 index 00000000000..f9a1f221d6f --- /dev/null +++ b/docker/jupyter/unsloth_labext/src/splash.ts @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0 +// +// Replace the JupyterLab loading splash with a spinning Unsloth logo. Provides +// the core ISplashScreen token; the stock @jupyterlab/apputils-extension:splash +// is disabled + locked at image build time so this is the only provider. The +// animation honors prefers-reduced-motion and keeps the default loader footprint. + +import { JupyterFrontEndPlugin } from '@jupyterlab/application'; +import { ISplashScreen } from '@jupyterlab/apputils'; +import { DisposableDelegate, IDisposable } from '@lumino/disposable'; +import { UNSLOTH_LOGO_DATA_URI } from './logo'; +import { SHORT_LABEL } from './branding'; + +const STYLE_ID = 'unsloth-splash-style'; +const SPLASH_ID = 'unsloth-splash'; + +function ensureStyle(): void { + if (document.getElementById(STYLE_ID)) { + return; + } + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` +#${SPLASH_ID} { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--jp-layout-color0, hsl(70, 8%, 12%)); +} +#${SPLASH_ID} img { + height: 72px; + width: 72px; + animation: unsloth-splash-spin 1.2s linear infinite; +} +#${SPLASH_ID} .unsloth-splash-label { + margin-top: 14px; + font-size: 13px; + opacity: 0.7; + font-family: sans-serif; + color: var(--jp-ui-font-color1, hsl(60, 30%, 92%)); +} +@keyframes unsloth-splash-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +@media (prefers-reduced-motion: reduce) { + #${SPLASH_ID} img { animation: none; } +} +`; + document.head.appendChild(style); +} + +const splashPlugin: JupyterFrontEndPlugin = { + id: 'unsloth-jupyterlab:splash', + description: 'Unsloth spinning-logo loading splash.', + autoStart: true, + provides: ISplashScreen, + activate: (): ISplashScreen => { + return { + show: (): IDisposable => { + ensureStyle(); + const overlay = document.createElement('div'); + overlay.id = SPLASH_ID; + + const img = document.createElement('img'); + img.src = UNSLOTH_LOGO_DATA_URI; + img.alt = 'Unsloth'; + overlay.appendChild(img); + + const label = document.createElement('div'); + label.className = 'unsloth-splash-label'; + label.textContent = SHORT_LABEL; + overlay.appendChild(label); + + document.body.appendChild(overlay); + return new DisposableDelegate(() => { + overlay.remove(); + }); + } + }; + } +}; + +export default splashPlugin; diff --git a/docker/jupyter/unsloth_labext/src/uiChrome.ts b/docker/jupyter/unsloth_labext/src/uiChrome.ts index ea0bfc84a8f..673344d2529 100644 --- a/docker/jupyter/unsloth_labext/src/uiChrome.ts +++ b/docker/jupyter/unsloth_labext/src/uiChrome.ts @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0 + import { ILabShell, JupyterFrontEnd, diff --git a/docker/studio_launch.sh b/docker/studio_launch.sh index 5ffffa1e4c4..8ec3f4ed078 100644 --- a/docker/studio_launch.sh +++ b/docker/studio_launch.sh @@ -103,6 +103,21 @@ if [[ -n "${PUBLIC_SSH_KEY}" ]] && command -v sshd >/dev/null 2>&1; then fi mkdir -p /workspace + +# --- Branding / AGPLv3 attribution integrity gate (whole container) ----------- +# This image is built by Unsloth and ships under the GNU AGPLv3. Refuse to start +# anything if the Unsloth attribution (Help/About, spinning-logo splash, branded +# login, theme, AGPLv3 license text + source links) has been stripped or altered. +# The same checker also runs as a jupyter_server extension (refuses JupyterLab on +# its own) and at image-build time. Bypass for local development if ever needed: +# UNSLOTH_SKIP_BRANDING_CHECK=1 (intended for Unsloth's own debugging, not resale). +if [[ "${UNSLOTH_SKIP_BRANDING_CHECK:-0}" != "1" ]]; then + if ! /opt/unsloth-venv/bin/python -m unsloth_branding --verify; then + echo "Refusing to start the container." >&2 + exit 1 + fi +fi + echo "Unsloth Studio -> http://localhost:8000 (first-boot password below)" echo "JupyterLab -> http://localhost:${JUPYTER_PORT} (${JUPYTER_NOTE})" if [[ "${UNSLOTH_JUPYTER_CLOUDFLARE}" == "1" ]]; then diff --git a/tests/studio/test_branding_guard.py b/tests/studio/test_branding_guard.py new file mode 100644 index 00000000000..2956220ee22 --- /dev/null +++ b/tests/studio/test_branding_guard.py @@ -0,0 +1,199 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0 +"""Tests for the Unsloth Docker Studio branding / AGPLv3 integrity guard. + +verify_branding() is exercised against a staged temp tree that mirrors the +installed image layout, so no container or built labextension is required: + * positive: a faithful tree passes (no problems). + * negative: removing/altering each attribution marker is detected. + * no-encoding: the attribution sources carry no base64/decoder obfuscation + (plain readable strings only -- the only data URI is the logo *image*). +""" + +import json +import os +import sys + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, "..", "..")) +sys.path.insert(0, os.path.join(REPO, "docker", "jupyter")) + +import unsloth_branding as ub # noqa: E402 + + +def _stage(tmp_path): + """Create a faithful copy of the installed branding layout; return paths.""" + venv_share = tmp_path / "venv-share" + js_dir = tmp_path / "jupyter_server" + + (venv_share).mkdir(parents = True) + (venv_share / "UNSLOTH_LICENSE.AGPL-3.0").write_text( + " GNU AFFERO GENERAL PUBLIC LICENSE\n" + " Version 3, 19 November 2007\n" + " Copyright (C) 2007 Free Software Foundation, Inc.\n", + encoding = "utf-8", + ) + + (venv_share / "lab" / "settings").mkdir(parents = True) + (venv_share / "lab" / "settings" / "overrides.json").write_text( + json.dumps({"@jupyterlab/apputils-extension:themes": {"theme": ub.THEME_NAME}}), + encoding = "utf-8", + ) + + labext = venv_share / "labextensions" / ub.LABEXT_NAME + (labext / "static").mkdir(parents = True) + (labext / "package.json").write_text(json.dumps({"name": ub.LABEXT_NAME}), encoding = "utf-8") + bundle = " ".join([ + ub.PHRASE, + ub.SHORT_LABEL, + ub.COPYRIGHT, + ub.AGPL_URL, + ub.ABOUT_PLUGIN_ID, + ub.SPLASH_PLUGIN_ID, + ub.LOGO_DATA_URI_PREFIX + "AAAAdummyimagebytes", + ]) + (labext / "static" / "remoteEntry.abc123.js").write_text(bundle, encoding = "utf-8") + + (js_dir / "templates").mkdir(parents = True) + (js_dir / "templates" / "login.html").write_text( + "Built by Unsloth. Licensed under the GNU AGPLv3.\n" + "Copyright 2026-Present the Unsloth team.\n" + "https://github.com/unslothai/unsloth\n", + encoding = "utf-8", + ) + + (js_dir / "static" / "favicons").mkdir(parents = True) + (js_dir / "static" / "favicons" / "favicon.ico").write_bytes(b"\x00\x00\x01\x00icon") + (js_dir / "static" / "logo").mkdir(parents = True) + (js_dir / "static" / "logo" / "logo.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo") + + return ub.resolve_paths(venv_share = str(venv_share), jupyter_server_dir = str(js_dir)) + + +def test_positive_clean_tree_passes(tmp_path): + paths = _stage(tmp_path) + assert ub.verify_branding(paths) == [] + + +# --- negative mutations: each strips one attribution marker -------------------- +def _remove_license(paths): + os.remove(paths["license"]) + + +def _blank_license(paths): + with open(paths["license"], "w", encoding = "utf-8") as f: + f.write("All rights reserved. Proprietary. Resold by someone else.\n") + + +def _remove_login(paths): + os.remove(paths["login"]) + + +def _strip_login_source(paths): + with open(paths["login"], encoding = "utf-8") as f: + text = f.read() + with open(paths["login"], "w", encoding = "utf-8") as f: + f.write(text.replace(ub.SOURCE_URL, "https://example.com/forks")) + + +def _strip_login_copyright(paths): + with open(paths["login"], encoding = "utf-8") as f: + text = f.read() + with open(paths["login"], "w", encoding = "utf-8") as f: + f.write(text.replace(ub.COPYRIGHT, "Copyright someone else")) + + +def _drop_theme(paths): + with open(paths["overrides"], "w", encoding = "utf-8") as f: + f.write("{}") + + +def _rebrand_labext(paths): + with open(paths["labext_pkg"], "w", encoding = "utf-8") as f: + f.write(json.dumps({"name": "totally-not-unsloth"})) + + +def _strip_bundle_phrase(paths): + import glob + + for path in glob.glob(os.path.join(paths["labext_static"], "*.js")): + with open(path, encoding = "utf-8") as f: + text = f.read() + with open(path, "w", encoding = "utf-8") as f: + f.write(text.replace(ub.PHRASE, "").replace(ub.SHORT_LABEL, "")) + + +def _strip_bundle_logo(paths): + import glob + + for path in glob.glob(os.path.join(paths["labext_static"], "*.js")): + with open(path, encoding = "utf-8") as f: + text = f.read() + with open(path, "w", encoding = "utf-8") as f: + f.write(text.replace(ub.LOGO_DATA_URI_PREFIX, "data:image/png;base64,XXXX")) + + +def _remove_logo_png(paths): + os.remove(paths["logo"]) + + +def _empty_favicon(paths): + open(paths["favicon"], "w").close() + + +@pytest.mark.parametrize( + "mutate", + [ + _remove_license, + _blank_license, + _remove_login, + _strip_login_source, + _strip_login_copyright, + _drop_theme, + _rebrand_labext, + _strip_bundle_phrase, + _strip_bundle_logo, + _remove_logo_png, + _empty_favicon, + ], +) +def test_negative_each_marker_is_enforced(tmp_path, mutate): + paths = _stage(tmp_path) + assert ub.verify_branding(paths) == [], "baseline should be clean before mutation" + mutate(paths) + problems = ub.verify_branding(paths) + assert problems, "stripping " + mutate.__name__ + " must be detected" + + +def test_attribution_sources_have_no_encoded_obfuscation(): + """Plain readable strings only -- no base64/decoder tricks (antivirus-safe).""" + src_dir = os.path.join(REPO, "docker", "jupyter") + files = [ + os.path.join(src_dir, "unsloth_branding.py"), + os.path.join(src_dir, "unsloth_labext", "src", "branding.ts"), + os.path.join(src_dir, "unsloth_labext", "src", "about.ts"), + os.path.join(src_dir, "unsloth_labext", "src", "splash.ts"), + ] + forbidden = ["b64decode", "b64encode", "atob(", "btoa(", "fromCharCode", "unescape(", "rot13", "codecs.decode"] + for path in files: + with open(path, encoding = "utf-8") as f: + text = f.read() + for token in forbidden: + assert token not in text, path + " uses obfuscation token: " + token + + +def test_canonical_phrase_is_plain_text_in_definition_files(): + """The attribution lives as plain readable text in both definition files. + + branding.ts holds the full PHRASE as ONE contiguous literal (so webpack keeps + it whole in the bundle for the guard to grep). unsloth_branding.py keeps the + markers as plain constants (the runtime PHRASE value matches, even though the + source wraps it across adjacent literals).""" + src_dir = os.path.join(REPO, "docker", "jupyter") + ts = open(os.path.join(src_dir, "unsloth_labext", "src", "branding.ts"), encoding = "utf-8").read() + assert ub.PHRASE in ts, "branding.ts must hold the full PHRASE as one literal" + py = open(os.path.join(src_dir, "unsloth_branding.py"), encoding = "utf-8").read() + for marker in (ub.SHORT_LABEL, ub.COPYRIGHT, ub.SOURCE_URL, ub.AGPL_URL, ub.THEME_NAME): + assert marker in py, "unsloth_branding.py missing plain marker: " + marker From d1a5b583836fef80b66dabdc76b5b35ac201ad68 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 07:44:16 +0000 Subject: [PATCH 11/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docker/jupyter/unsloth_branding.py | 3 +-- tests/studio/test_branding_guard.py | 37 +++++++++++++++++++---------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/docker/jupyter/unsloth_branding.py b/docker/jupyter/unsloth_branding.py index 44722dee7c4..f79a1b5f95b 100644 --- a/docker/jupyter/unsloth_branding.py +++ b/docker/jupyter/unsloth_branding.py @@ -58,7 +58,7 @@ LOGO_DATA_URI_PREFIX = "data:image/png;base64,iVBOR" -def resolve_paths(venv_share=None, jupyter_server_dir=None): +def resolve_paths(venv_share = None, jupyter_server_dir = None): """Resolve the installed locations of every checked branding asset. Defaults point at the live venv + the installed jupyter_server package. Tests @@ -68,7 +68,6 @@ def resolve_paths(venv_share=None, jupyter_server_dir=None): venv_share = os.path.join(sys.prefix, "share", "jupyter") if jupyter_server_dir is None: import jupyter_server # local import: only needed for live resolution - jupyter_server_dir = os.path.dirname(jupyter_server.__file__) labext_dir = os.path.join(venv_share, "labextensions", LABEXT_NAME) return { diff --git a/tests/studio/test_branding_guard.py b/tests/studio/test_branding_guard.py index 2956220ee22..0c92c033460 100644 --- a/tests/studio/test_branding_guard.py +++ b/tests/studio/test_branding_guard.py @@ -45,15 +45,17 @@ def _stage(tmp_path): labext = venv_share / "labextensions" / ub.LABEXT_NAME (labext / "static").mkdir(parents = True) (labext / "package.json").write_text(json.dumps({"name": ub.LABEXT_NAME}), encoding = "utf-8") - bundle = " ".join([ - ub.PHRASE, - ub.SHORT_LABEL, - ub.COPYRIGHT, - ub.AGPL_URL, - ub.ABOUT_PLUGIN_ID, - ub.SPLASH_PLUGIN_ID, - ub.LOGO_DATA_URI_PREFIX + "AAAAdummyimagebytes", - ]) + bundle = " ".join( + [ + ub.PHRASE, + ub.SHORT_LABEL, + ub.COPYRIGHT, + ub.AGPL_URL, + ub.ABOUT_PLUGIN_ID, + ub.SPLASH_PLUGIN_ID, + ub.LOGO_DATA_URI_PREFIX + "AAAAdummyimagebytes", + ] + ) (labext / "static" / "remoteEntry.abc123.js").write_text(bundle, encoding = "utf-8") (js_dir / "templates").mkdir(parents = True) @@ -117,7 +119,6 @@ def _rebrand_labext(paths): def _strip_bundle_phrase(paths): import glob - for path in glob.glob(os.path.join(paths["labext_static"], "*.js")): with open(path, encoding = "utf-8") as f: text = f.read() @@ -127,7 +128,6 @@ def _strip_bundle_phrase(paths): def _strip_bundle_logo(paths): import glob - for path in glob.glob(os.path.join(paths["labext_static"], "*.js")): with open(path, encoding = "utf-8") as f: text = f.read() @@ -176,7 +176,16 @@ def test_attribution_sources_have_no_encoded_obfuscation(): os.path.join(src_dir, "unsloth_labext", "src", "about.ts"), os.path.join(src_dir, "unsloth_labext", "src", "splash.ts"), ] - forbidden = ["b64decode", "b64encode", "atob(", "btoa(", "fromCharCode", "unescape(", "rot13", "codecs.decode"] + forbidden = [ + "b64decode", + "b64encode", + "atob(", + "btoa(", + "fromCharCode", + "unescape(", + "rot13", + "codecs.decode", + ] for path in files: with open(path, encoding = "utf-8") as f: text = f.read() @@ -192,7 +201,9 @@ def test_canonical_phrase_is_plain_text_in_definition_files(): markers as plain constants (the runtime PHRASE value matches, even though the source wraps it across adjacent literals).""" src_dir = os.path.join(REPO, "docker", "jupyter") - ts = open(os.path.join(src_dir, "unsloth_labext", "src", "branding.ts"), encoding = "utf-8").read() + ts = open( + os.path.join(src_dir, "unsloth_labext", "src", "branding.ts"), encoding = "utf-8" + ).read() assert ub.PHRASE in ts, "branding.ts must hold the full PHRASE as one literal" py = open(os.path.join(src_dir, "unsloth_branding.py"), encoding = "utf-8").read() for marker in (ub.SHORT_LABEL, ub.COPYRIGHT, ub.SOURCE_URL, ub.AGPL_URL, ub.THEME_NAME): From 2482719695e8f35f06f25b107e2cbb888daee21c Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 27 Jun 2026 08:55:04 +0000 Subject: [PATCH 12/19] docker: address #6681 review round 2 (colab magics, output select, branding guard) - unsloth_colab_compat.py: only hoist a leading `%%` cell magic above the Colab `#@title` form for magics whose body runs as code (capture/time/bash/python/ ...). Content magics (%%writefile, %%html, %%latex, ...) are left untouched so the form comment is never injected into the written file / rendered output. - outputSelect.ts: stop trusting the text selection anchor to decide ownership of Ctrl/Cmd+A. A stale selection inside an output survives a click onto a command-mode cell or the file browser, which made select-all keep re-selecting the old output. Gate on the keystroke target or the last pointer-down (reset to null on any click outside an output) instead. - unsloth_branding.py: also reject page_config.json that disables the Unsloth labextension or any of its plugin ids via disabledExtensions (dict or list form); that leaves the bundle on disk so the prior checks passed while the logo/About/splash attribution was stripped at load. Lock unsloth-jupyterlab in Dockerfile.studio as well (defense in depth), and add guard tests. --- docker/Dockerfile.studio | 3 +- docker/jupyter/unsloth_branding.py | 48 ++++++++++++++++++- .../unsloth_labext/src/outputSelect.ts | 27 +++++++---- docker/unsloth_colab_compat.py | 30 +++++++++++- tests/studio/test_branding_guard.py | 36 +++++++++++++- tests/validate_studio_features.py | 7 +++ 6 files changed, 136 insertions(+), 15 deletions(-) diff --git a/docker/Dockerfile.studio b/docker/Dockerfile.studio index d0f8cfb510e..d6480704f31 100644 --- a/docker/Dockerfile.studio +++ b/docker/Dockerfile.studio @@ -240,7 +240,8 @@ RUN JS="$(/opt/unsloth-venv/bin/python -c 'import os, jupyter_server; print(os.p && /opt/unsloth-venv/bin/jupyter labextension disable @jupyterlab/application-extension:logo \ && /opt/unsloth-venv/bin/jupyter labextension lock @jupyterlab/application-extension:logo \ && /opt/unsloth-venv/bin/jupyter labextension disable @jupyterlab/apputils-extension:splash \ - && /opt/unsloth-venv/bin/jupyter labextension lock @jupyterlab/apputils-extension:splash + && /opt/unsloth-venv/bin/jupyter labextension lock @jupyterlab/apputils-extension:splash \ + && /opt/unsloth-venv/bin/jupyter labextension lock unsloth-jupyterlab # Branding integrity guard: the canonical attribution checker (also a # jupyter_server extension), the full AGPLv3 license text, and the config that # enables the extension. Installed into the base venv so it is on the jupyter diff --git a/docker/jupyter/unsloth_branding.py b/docker/jupyter/unsloth_branding.py index 44722dee7c4..5287869a6aa 100644 --- a/docker/jupyter/unsloth_branding.py +++ b/docker/jupyter/unsloth_branding.py @@ -58,7 +58,7 @@ LOGO_DATA_URI_PREFIX = "data:image/png;base64,iVBOR" -def resolve_paths(venv_share=None, jupyter_server_dir=None): +def resolve_paths(venv_share=None, jupyter_server_dir=None, config_dirs=None): """Resolve the installed locations of every checked branding asset. Defaults point at the live venv + the installed jupyter_server package. Tests @@ -71,6 +71,21 @@ def resolve_paths(venv_share=None, jupyter_server_dir=None): jupyter_server_dir = os.path.dirname(jupyter_server.__file__) labext_dir = os.path.join(venv_share, "labextensions", LABEXT_NAME) + + # Every page_config.json JupyterLab merges to compute disabledExtensions: the + # app-settings file plus a labconfig/ file under each jupyter config dir + # (where `jupyter labextension disable` writes). Tests pass config_dirs=[] for + # a hermetic tree; live resolution scans the real jupyter config path. + if config_dirs is None: + try: + from jupyter_core.paths import jupyter_config_path + + config_dirs = jupyter_config_path() + except Exception: + config_dirs = [] + page_configs = [os.path.join(venv_share, "lab", "settings", "page_config.json")] + page_configs += [os.path.join(d, "labconfig", "page_config.json") for d in config_dirs] + return { "license": os.path.join(venv_share, "UNSLOTH_LICENSE.AGPL-3.0"), "login": os.path.join(jupyter_server_dir, "templates", "login.html"), @@ -80,6 +95,7 @@ def resolve_paths(venv_share=None, jupyter_server_dir=None): "labext_static": os.path.join(labext_dir, "static"), "favicon": os.path.join(jupyter_server_dir, "static", "favicons", "favicon.ico"), "logo": os.path.join(jupyter_server_dir, "static", "logo", "logo.png"), + "page_configs": page_configs, } @@ -177,6 +193,36 @@ def verify_branding(paths = None): if not _nonempty_file(paths["logo"]): problems.append("missing or empty logo: " + paths["logo"]) + # 7. No page_config.json disables the Unsloth extension or its plugins. + # Disabling via `disabledExtensions` leaves the static bundle on disk (so + # check 5 still passes) yet strips the logo / About / splash at load. Since + # the guard exists to refuse stripped attribution, reject that too. Stock + # plugins we disable ourselves (logo/splash) are unaffected -- we only flag + # ids belonging to unsloth-jupyterlab. + for pc_path in paths.get("page_configs", []): + text = _read(pc_path) + if not text: + continue + try: + disabled = json.loads(text).get("disabledExtensions", {}) + except ValueError: + problems.append("page_config.json is not valid JSON: " + pc_path) + continue + # Modern JupyterLab uses a {id: bool} map; older configs used a list. + if isinstance(disabled, dict): + disabled_ids = [k for k, v in disabled.items() if v] + elif isinstance(disabled, (list, tuple)): + disabled_ids = list(disabled) + else: + disabled_ids = [] + for ident in disabled_ids: + if not isinstance(ident, str): + continue + if ident == LABEXT_NAME or ident.startswith(LABEXT_NAME + ":"): + problems.append( + "page_config.json disables Unsloth attribution '" + ident + "': " + pc_path + ) + return problems diff --git a/docker/jupyter/unsloth_labext/src/outputSelect.ts b/docker/jupyter/unsloth_labext/src/outputSelect.ts index a1641a967f4..d7329f22151 100644 --- a/docker/jupyter/unsloth_labext/src/outputSelect.ts +++ b/docker/jupyter/unsloth_labext/src/outputSelect.ts @@ -22,9 +22,16 @@ import { * - 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. + * - the keystroke target OR the last pointer-down landed inside an output area. + * + * We deliberately do NOT use the text selection anchor to decide ownership: a + * stale selection inside an output survives a later click onto a command-mode + * cell or the file browser (clicking a non-text region does not always move the + * anchor), which would make Ctrl/Cmd+A keep re-selecting that old output instead + * of doing the normal select-all in the new context. The last pointer-down is + * reset on every click (to null when the click is outside any output), so it + * tracks the user's current intent; in every other case we do nothing and + * JupyterLab keeps its default behaviour. */ // Output containers, widest first. `.jp-OutputArea-output` is a single output; @@ -95,13 +102,13 @@ const outputSelectPlugin: JupyterFrontEndPlugin = { 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; - } + // Own the chord only when the user is actually in an output right now: + // the keystroke target, else the last place they clicked. We do NOT trust + // the text selection anchor -- it goes stale after clicking away from a + // previously selected output (see the file header), which would otherwise + // hijack select-all in the notebook / file browser. + const output = + closestOutput(event.target as Node | null) ?? lastPointerOutput; if (!output) { return; } diff --git a/docker/unsloth_colab_compat.py b/docker/unsloth_colab_compat.py index be3741fc77b..9899979d6b9 100644 --- a/docker/unsloth_colab_compat.py +++ b/docker/unsloth_colab_compat.py @@ -19,6 +19,13 @@ captures them. Idempotent and fully guarded: any problem returns the input unchanged, so a cell never breaks because of this helper. +The hoist is restricted to cell magics whose body is executed as code (Python or +shell), where a moved-down `#@title`/comment line stays an inert comment. Magics +that treat the body as literal content (`%%writefile`, `%%file`, `%%html`, +`%%javascript`, `%%latex`, `%%markdown`, `%%svg`, ...) are left untouched: moving +the Colab form comment into their body would write/render it and corrupt the +generated file or output. + This mirrors unsloth_nb_compat.register_ipython(): it is wired from the baked IPython startup file (docker/unsloth_ipython_startup.py). """ @@ -27,8 +34,21 @@ import sys +# Cell magics whose body is executed as code (Python or shell), so a hoisted +# `#@title`/`#@param`/comment line stays an inert comment. We ONLY hoist these. +# Anything not listed (content/data magics like %%writefile, %%file, %%html, +# %%javascript, %%latex, %%markdown, %%svg) is left untouched, because injecting +# the Colab form comment into its body would corrupt the written file / output. +_SAFE_CELL_MAGICS = frozenset({ + "capture", # the Colab install pattern: suppress pip/install output + "time", "timeit", "prun", "debug", + "bash", "sh", "shell", + "python", "python2", "python3", "pypy", +}) + + def colab_cell_magic_fix(lines): - """Hoist a `%%` cell magic above leading blank/comment lines. + """Hoist a safe `%%` 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. @@ -43,7 +63,13 @@ def colab_cell_magic_fix(lines): # 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 :] + name = stripped[2:].split(maxsplit=1) + name = name[0] if name else "" + if name in _SAFE_CELL_MAGICS: + return [line] + skipped + lines[i + 1 :] + # Content/data magic (%%writefile, %%html, ...): do not move the + # comment into its body. Leave the cell exactly as written. + return lines return lines # already on top, or not a magic return lines # all blank/comment -> nothing to do except Exception: diff --git a/tests/studio/test_branding_guard.py b/tests/studio/test_branding_guard.py index 2956220ee22..46f94ebac50 100644 --- a/tests/studio/test_branding_guard.py +++ b/tests/studio/test_branding_guard.py @@ -69,7 +69,11 @@ def _stage(tmp_path): (js_dir / "static" / "logo").mkdir(parents = True) (js_dir / "static" / "logo" / "logo.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo") - return ub.resolve_paths(venv_share = str(venv_share), jupyter_server_dir = str(js_dir)) + # config_dirs = [] keeps the tree hermetic (no host jupyter config scanned); + # page_config tests write to the app-settings page_config.json directly. + return ub.resolve_paths( + venv_share = str(venv_share), jupyter_server_dir = str(js_dir), config_dirs = [], + ) def test_positive_clean_tree_passes(tmp_path): @@ -143,6 +147,22 @@ def _empty_favicon(paths): open(paths["favicon"], "w").close() +def _disable_unsloth_ext(paths): + with open(paths["page_configs"][0], "w", encoding = "utf-8") as f: + json.dump({"disabledExtensions": {ub.LABEXT_NAME: True}}, f) + + +def _disable_unsloth_plugin(paths): + with open(paths["page_configs"][0], "w", encoding = "utf-8") as f: + json.dump({"disabledExtensions": {ub.ABOUT_PLUGIN_ID: True}}, f) + + +def _disable_unsloth_ext_list_form(paths): + # Older JupyterLab configs used a list of ids rather than an {id: bool} map. + with open(paths["page_configs"][0], "w", encoding = "utf-8") as f: + json.dump({"disabledExtensions": [ub.SPLASH_PLUGIN_ID]}, f) + + @pytest.mark.parametrize( "mutate", [ @@ -157,6 +177,9 @@ def _empty_favicon(paths): _strip_bundle_logo, _remove_logo_png, _empty_favicon, + _disable_unsloth_ext, + _disable_unsloth_plugin, + _disable_unsloth_ext_list_form, ], ) def test_negative_each_marker_is_enforced(tmp_path, mutate): @@ -167,6 +190,17 @@ def test_negative_each_marker_is_enforced(tmp_path, mutate): assert problems, "stripping " + mutate.__name__ + " must be detected" +def test_disabling_stock_plugins_is_allowed(tmp_path): + """We disable the stock logo/splash ourselves -- the guard must not flag those.""" + paths = _stage(tmp_path) + with open(paths["page_configs"][0], "w", encoding = "utf-8") as f: + json.dump({"disabledExtensions": { + "@jupyterlab/application-extension:logo": True, + "@jupyterlab/apputils-extension:splash": True, + }}, f) + assert ub.verify_branding(paths) == [] + + def test_attribution_sources_have_no_encoded_obfuscation(): """Plain readable strings only -- no base64/decoder tricks (antivirus-safe).""" src_dir = os.path.join(REPO, "docker", "jupyter") diff --git a/tests/validate_studio_features.py b/tests/validate_studio_features.py index f4117d30103..85e33100014 100644 --- a/tests/validate_studio_features.py +++ b/tests/validate_studio_features.py @@ -53,6 +53,13 @@ def test_colab_compat() -> None: # non-magic cell untouched plain = ["x = 1\n", "y = 2\n"] check("plain cell untouched", m.colab_cell_magic_fix(plain) == plain) + # content/data magic (%%writefile) NOT hoisted -- never inject the #@title + # comment into the written file body + wf = ["#@title Config\n", "%%writefile config.json\n", "{}\n"] + check("content magic (%%writefile) left untouched", m.colab_cell_magic_fix(wf) == wf) + # safe magic with arg still hoisted + bash = ["#@title Run\n", "%%bash\n", "echo hi\n"] + check("safe magic (%%bash) hoisted", m.colab_cell_magic_fix(bash)[0] == "%%bash\n") # -------------------------------------------------------------------------- From 2c190aaaca6a88667ace21749baf4ce5ee6f04ea Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 08:59:00 +0000 Subject: [PATCH 13/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docker/jupyter/unsloth_branding.py | 6 +++++- docker/unsloth_colab_compat.py | 24 +++++++++++++++++------- tests/studio/test_branding_guard.py | 17 ++++++++++++----- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/docker/jupyter/unsloth_branding.py b/docker/jupyter/unsloth_branding.py index 7c8db0ccfc3..7a807f53777 100644 --- a/docker/jupyter/unsloth_branding.py +++ b/docker/jupyter/unsloth_branding.py @@ -58,7 +58,11 @@ LOGO_DATA_URI_PREFIX = "data:image/png;base64,iVBOR" -def resolve_paths(venv_share = None, jupyter_server_dir = None, config_dirs = None): +def resolve_paths( + venv_share = None, + jupyter_server_dir = None, + config_dirs = None, +): """Resolve the installed locations of every checked branding asset. Defaults point at the live venv + the installed jupyter_server package. Tests diff --git a/docker/unsloth_colab_compat.py b/docker/unsloth_colab_compat.py index 9899979d6b9..224ce9bb882 100644 --- a/docker/unsloth_colab_compat.py +++ b/docker/unsloth_colab_compat.py @@ -39,12 +39,22 @@ # Anything not listed (content/data magics like %%writefile, %%file, %%html, # %%javascript, %%latex, %%markdown, %%svg) is left untouched, because injecting # the Colab form comment into its body would corrupt the written file / output. -_SAFE_CELL_MAGICS = frozenset({ - "capture", # the Colab install pattern: suppress pip/install output - "time", "timeit", "prun", "debug", - "bash", "sh", "shell", - "python", "python2", "python3", "pypy", -}) +_SAFE_CELL_MAGICS = frozenset( + { + "capture", # the Colab install pattern: suppress pip/install output + "time", + "timeit", + "prun", + "debug", + "bash", + "sh", + "shell", + "python", + "python2", + "python3", + "pypy", + } +) def colab_cell_magic_fix(lines): @@ -63,7 +73,7 @@ def colab_cell_magic_fix(lines): # 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: - name = stripped[2:].split(maxsplit=1) + name = stripped[2:].split(maxsplit = 1) name = name[0] if name else "" if name in _SAFE_CELL_MAGICS: return [line] + skipped + lines[i + 1 :] diff --git a/tests/studio/test_branding_guard.py b/tests/studio/test_branding_guard.py index dd88306dd18..372602dba8f 100644 --- a/tests/studio/test_branding_guard.py +++ b/tests/studio/test_branding_guard.py @@ -74,7 +74,9 @@ def _stage(tmp_path): # config_dirs = [] keeps the tree hermetic (no host jupyter config scanned); # page_config tests write to the app-settings page_config.json directly. return ub.resolve_paths( - venv_share = str(venv_share), jupyter_server_dir = str(js_dir), config_dirs = [], + venv_share = str(venv_share), + jupyter_server_dir = str(js_dir), + config_dirs = [], ) @@ -194,10 +196,15 @@ def test_disabling_stock_plugins_is_allowed(tmp_path): """We disable the stock logo/splash ourselves -- the guard must not flag those.""" paths = _stage(tmp_path) with open(paths["page_configs"][0], "w", encoding = "utf-8") as f: - json.dump({"disabledExtensions": { - "@jupyterlab/application-extension:logo": True, - "@jupyterlab/apputils-extension:splash": True, - }}, f) + json.dump( + { + "disabledExtensions": { + "@jupyterlab/application-extension:logo": True, + "@jupyterlab/apputils-extension:splash": True, + } + }, + f, + ) assert ub.verify_branding(paths) == [] From 04452eb581adffb2615be6439ead0b715776fb06 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 29 Jun 2026 05:16:27 +0000 Subject: [PATCH 14/19] labext: pin JupyterLab extension deps; confirm.ps1 /login probe Pin the unsloth-jupyterlab npm deps to exact versions matching the baked jupyterlab==4.6.0 (builder stays 4.5.9, its newest release) instead of floating ^/~ ranges, so the same commit always builds the same labextension bundle. Also probe JupyterLab /login (not /api, which 403s behind a password hash) in the Windows confirmation script. --- docker/docker_confirm.ps1 | 5 ++-- docker/jupyter/unsloth_labext/package.json | 28 +++++++++++----------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/docker/docker_confirm.ps1 b/docker/docker_confirm.ps1 index 9ee659726a4..f5388772e6c 100644 --- a/docker/docker_confirm.ps1 +++ b/docker/docker_confirm.ps1 @@ -206,12 +206,13 @@ if (-not $script:STUDIO_CID) { $okStudio = $false; $okJupyter = $false foreach ($i in 1..60) { if (-not $okStudio) { try { Invoke-WebRequest -UseBasicParsing -Uri "http://localhost:$PORT_STUDIO/api/health" -TimeoutSec 4 | Out-Null; $okStudio = $true } catch {} } - if (-not $okJupyter) { try { Invoke-WebRequest -UseBasicParsing -Uri "http://localhost:$PORT_JUPYTER/api" -TimeoutSec 4 | Out-Null; $okJupyter = $true } catch {} } + # /login, not /api: a password hash is always configured so /api returns 403. + if (-not $okJupyter) { try { Invoke-WebRequest -UseBasicParsing -Uri "http://localhost:$PORT_JUPYTER/login" -TimeoutSec 4 | Out-Null; $okJupyter = $true } catch {} } if ($okStudio -and $okJupyter) { break } Start-Sleep -Seconds 5 } if ($okStudio) { Ok "Studio /api/health healthy" } else { Bad "Studio /api/health never went healthy (docker logs $($script:STUDIO_CID.Substring(0,12)))"; docker logs --tail 15 $script:STUDIO_CID 2>&1 | ForEach-Object { Info $_ } } - if ($okJupyter) { Ok "JupyterLab /api responding" } else { Bad "JupyterLab /api never responded" } + if ($okJupyter) { Ok "JupyterLab /login responding" } else { Bad "JupyterLab /login never responded" } } Hr diff --git a/docker/jupyter/unsloth_labext/package.json b/docker/jupyter/unsloth_labext/package.json index 80284ed2bb1..1201680a06d 100644 --- a/docker/jupyter/unsloth_labext/package.json +++ b/docker/jupyter/unsloth_labext/package.json @@ -29,22 +29,22 @@ "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/mainmenu": "^4.5.0", - "@jupyterlab/notebook": "^4.5.0", - "@jupyterlab/theme-dark-extension": "^4.5.0", - "@lumino/disposable": "^2.0.0", - "@lumino/widgets": "^2.0.0" + "@codemirror/state": "6.5.4", + "@codemirror/view": "6.39.14", + "@jupyterlab/application": "4.6.0", + "@jupyterlab/apputils": "4.6.0", + "@jupyterlab/cells": "4.6.0", + "@jupyterlab/codemirror": "4.6.0", + "@jupyterlab/mainmenu": "4.6.0", + "@jupyterlab/notebook": "4.6.0", + "@jupyterlab/theme-dark-extension": "4.6.0", + "@lumino/disposable": "2.1.5", + "@lumino/widgets": "2.8.0" }, "devDependencies": { - "@jupyterlab/builder": "^4.5.0", - "rimraf": "^5.0.0", - "typescript": "~5.5.0" + "@jupyterlab/builder": "4.5.9", + "rimraf": "5.0.10", + "typescript": "5.5.4" }, "jupyterlab": { "extension": true, From 5d41a03b40341995dc6f99ceb292fc87ee582412 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 5 Jul 2026 14:09:48 +0000 Subject: [PATCH 15/19] docker: categorize AMD/domain notebooks and wire the feature validation into CI unsloth_nb_view.parse_readme only reset the folder section on level-3 (###) headings. The notebooks README carries level-1 domain headers (# AMD Notebooks, # Kaggle Notebooks) with their own nb/*.ipynb link tables and no intervening ###, so those notebooks were mis-filed under the previous stale section (all 148 AMD notebooks landed in Other Notebooks on an --amd build). Reset on any heading level and strip a leading emoji/symbol run so the domain notebooks get their own clean folder. Also run tests/validate_studio_features.py explicitly in the repo CPU job. It is named validate_* (not test_*) so pytest never collected it, which meant a regression in the notebook view, Colab compat, strip, JupyterLab defaults or login branding failed CI only when run by hand. --- .github/workflows/studio-backend-ci.yml | 6 ++++++ docker/unsloth_nb_view.py | 11 ++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/studio-backend-ci.yml b/.github/workflows/studio-backend-ci.yml index 3022127a2b7..c0abd78d746 100644 --- a/.github/workflows/studio-backend-ci.yml +++ b/.github/workflows/studio-backend-ci.yml @@ -238,3 +238,9 @@ jobs: echo "::endgroup::" done + - name: Docker JupyterLab/notebook feature validation + # Named validate_studio_features.py (not test_*.py) so pytest's default + # discovery skips it; run it explicitly here so a regression in the + # notebook view, Colab compat, strip, JupyterLab defaults or login + # branding fails CI instead of only when someone runs it by hand. + run: python tests/validate_studio_features.py diff --git a/docker/unsloth_nb_view.py b/docker/unsloth_nb_view.py index 9c74081999e..0d6bb5a4f3c 100644 --- a/docker/unsloth_nb_view.py +++ b/docker/unsloth_nb_view.py @@ -51,6 +51,10 @@ def clean_section(title): # 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() + # Strip a leading run of emoji / symbols / punctuation that some domain + # headers lead with (e.g. "🐧 AMD Notebooks", "📒 Kaggle Notebooks") so the + # folder label is clean text. + title = re.sub(r"^[^\w]+", "", title) title = title.replace("-", " ").replace("/", " ") title = re.sub(r"\s+", " ", title).strip() return title @@ -74,8 +78,13 @@ def parse_readme(readme_path): rows = [] seen_pairs = set() # (section, filename) already emitted section = None + # Reset on ANY markdown heading, not just `###`. The catalog uses `#`/`##` + # domain headers (e.g. "# AMD Notebooks", "# Kaggle Notebooks") that carry + # their own `nb/*.ipynb` link tables directly, with no intervening `###`. + # Matching only `###` left `section` stale, so those links were mis-filed + # under the previous section instead of getting their own folder. for line in text.splitlines(): - m = re.match(r"^###\s+(.*)$", line) + m = re.match(r"^#{1,6}\s+(.*)$", line) if m: section = clean_section(m.group(1)) continue From 38df3879ed7f0beaae2cd080613f480d49b1955a Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 5 Jul 2026 15:34:42 +0000 Subject: [PATCH 16/19] labext: use caret ranges so jlpm dedups JupyterLab/Lumino singletons The exact pins introduced earlier (@jupyterlab/* 4.6.0, @lumino/widgets 2.8.0, @jupyterlab/builder 4.5.9) break the Dockerfile.studio labext-builder stage. Exact-pinning the framework packages defeats jlpm's (yarn classic) hoisting: transitive @jupyterlab deps request caret ranges that resolve to newer patch releases (e.g. @jupyterlab/ notebook pulls @jupyterlab/cells ^4.6.0 -> a newer patch), so jlpm installs a second nested copy alongside the exact top-level one. Two copies of @jupyterlab/cells and @lumino/widgets in the tree produce TS2345 "not assignable" errors (protected-member/identity mismatch) and the build fails. Caret ranges let jlpm collapse every @jupyterlab and @lumino package to a single hoisted copy, which is required for a JupyterLab prebuilt (federated) extension: at runtime those packages are shared singletons provided by the host JupyterLab, so the build-time versions only need to type-check against one consistent tree, not match an exact runtime patch. This is the version set the published image was built and validated with end to end. Verified by building the labext in isolation against the base image (Node 20 + bundled jlpm): caret ranges build clean (webpack compiled successfully); the exact pins fail with the duplicate-package TS errors. --- docker/jupyter/unsloth_labext/package.json | 28 +++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docker/jupyter/unsloth_labext/package.json b/docker/jupyter/unsloth_labext/package.json index 1201680a06d..80284ed2bb1 100644 --- a/docker/jupyter/unsloth_labext/package.json +++ b/docker/jupyter/unsloth_labext/package.json @@ -29,22 +29,22 @@ "clean": "rimraf lib tsconfig.tsbuildinfo unsloth-jupyterlab/labextension" }, "dependencies": { - "@codemirror/state": "6.5.4", - "@codemirror/view": "6.39.14", - "@jupyterlab/application": "4.6.0", - "@jupyterlab/apputils": "4.6.0", - "@jupyterlab/cells": "4.6.0", - "@jupyterlab/codemirror": "4.6.0", - "@jupyterlab/mainmenu": "4.6.0", - "@jupyterlab/notebook": "4.6.0", - "@jupyterlab/theme-dark-extension": "4.6.0", - "@lumino/disposable": "2.1.5", - "@lumino/widgets": "2.8.0" + "@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/mainmenu": "^4.5.0", + "@jupyterlab/notebook": "^4.5.0", + "@jupyterlab/theme-dark-extension": "^4.5.0", + "@lumino/disposable": "^2.0.0", + "@lumino/widgets": "^2.0.0" }, "devDependencies": { - "@jupyterlab/builder": "4.5.9", - "rimraf": "5.0.10", - "typescript": "5.5.4" + "@jupyterlab/builder": "^4.5.0", + "rimraf": "^5.0.0", + "typescript": "~5.5.0" }, "jupyterlab": { "extension": true, From b558bc7de6aec5dcd63bbb832d96f98e0cf96e2a Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 5 Jul 2026 23:20:05 +0000 Subject: [PATCH 17/19] ci(studio-backend): trigger on docker/** so the JupyterLab feature validation guards docker-only changes The 'Docker JupyterLab/notebook feature validation' step runs tests/validate_studio_features.py, which checks docker/jupyter (the labextension, overrides.json, login branding) and the docker notebook helpers. The pull_request paths filter listed studio/unsloth/tests but not docker/**, so a PR that only touches docker/ would skip that step and a regression in those files could pass CI. Add docker/** so the validation runs whenever the files it checks change. --- .github/workflows/studio-backend-ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/studio-backend-ci.yml b/.github/workflows/studio-backend-ci.yml index c0abd78d746..b3392e7d077 100644 --- a/.github/workflows/studio-backend-ci.yml +++ b/.github/workflows/studio-backend-ci.yml @@ -30,6 +30,11 @@ on: - 'unsloth/**' - 'unsloth_cli/**' - 'tests/**' + # The "Docker JupyterLab/notebook feature validation" step below runs + # tests/validate_studio_features.py, which checks docker/jupyter (the + # labextension, overrides.json, login branding) and the docker notebook + # helpers. Without docker/** here a docker-only change skips that guard. + - 'docker/**' - 'pyproject.toml' - '.github/workflows/studio-backend-ci.yml' push: From 18bc5bf5d3bd509a729332aedbfa61929e170279 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Mon, 6 Jul 2026 04:22:30 +0000 Subject: [PATCH 18/19] jupyter: center the login card and place the attribution below it #site was a flex container using the default row direction with two children (the login card and the AGPLv3 attribution), so they rendered side by side: the card sat left of centre and the attribution floated up to the top-right. Stack them in a column so the card is horizontally centred and the attribution sits below it as a footer, matching the intended single-column layout. --- .github/workflows/studio-backend-ci.yml | 5 ----- docker/jupyter/login.html | 6 +++++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/studio-backend-ci.yml b/.github/workflows/studio-backend-ci.yml index b3392e7d077..c0abd78d746 100644 --- a/.github/workflows/studio-backend-ci.yml +++ b/.github/workflows/studio-backend-ci.yml @@ -30,11 +30,6 @@ on: - 'unsloth/**' - 'unsloth_cli/**' - 'tests/**' - # The "Docker JupyterLab/notebook feature validation" step below runs - # tests/validate_studio_features.py, which checks docker/jupyter (the - # labextension, overrides.json, login branding) and the docker notebook - # helpers. Without docker/** here a docker-only change skips that guard. - - 'docker/**' - 'pyproject.toml' - '.github/workflows/studio-backend-ci.yml' push: diff --git a/docker/jupyter/login.html b/docker/jupyter/login.html index a81b3447f2b..840c69c077a 100644 --- a/docker/jupyter/login.html +++ b/docker/jupyter/login.html @@ -18,7 +18,11 @@ /* Hide the stock top header (jupyter_server's index.css uses a higher- specificity selector, so force it); the centered card carries the brand. */ #header, .header-bar { display: none !important; } - #site { display: flex; justify-content: center; align-items: flex-start; } + /* Column, not the flex default row: #site holds two children (the login card + and the AGPLv3 attribution). As a row they sat side by side, pushing the card + left of centre and the attribution up to the top-right. Stack them so the card + is horizontally centred and the attribution sits below it as a footer. */ + #site { display: flex; flex-direction: column; align-items: center; justify-content: flex-start; } .unsloth-login-card { margin-top: 11vh; background: hsl(70, 8%, 18%); From ab241b56c2ec2f079bdca8fe4ea41da657faf702 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Mon, 6 Jul 2026 05:12:55 +0000 Subject: [PATCH 19/19] jupyter: refresh Studio attribution, About dialog and loading splash - Attribution now reads 'Built by the Unsloth team' with a single Apache 2.0 / AGPLv3 license link (to the repo license section) on the login page and in the About dialog, replacing the plain 'Built by Unsloth. Licensed under the GNU AGPLv3.' line. The integrity guard, its canonical PHRASE and the branding tests are updated to match. - About dialog: left-align the link rows so the labels line up instead of each row centering independently; add an 'Unsloth Reference' link to the docs, and a Licenses section listing Unsloth Studio (AGPLv3) and Unsloth Core (Apache 2.0) alongside the full license link. - Loading splash now reads 'Loading Unsloth Docker' instead of the attribution label, via a dedicated SPLASH_LABEL constant. --- docker/jupyter/login.html | 8 ++++--- docker/jupyter/unsloth_branding.py | 13 +++++++---- docker/jupyter/unsloth_labext/src/about.ts | 22 ++++++++++++++----- docker/jupyter/unsloth_labext/src/branding.ts | 12 +++++++--- docker/jupyter/unsloth_labext/src/splash.ts | 4 ++-- tests/studio/test_branding_guard.py | 3 ++- 6 files changed, 44 insertions(+), 18 deletions(-) diff --git a/docker/jupyter/login.html b/docker/jupyter/login.html index 840c69c077a..ac029434f74 100644 --- a/docker/jupyter/login.html +++ b/docker/jupyter/login.html @@ -61,8 +61,9 @@ .unsloth-login-card button:hover { background: hsl(160, 55%, 36%); } .unsloth-login-card .message { margin-top: 16px; font-size: 13px; } .unsloth-login-card .message.error { color: hsl(0, 75%, 68%); } - /* AGPLv3 attribution footer. Part of the Unsloth attribution set the image's - integrity guard verifies (Built by Unsloth + copyright + source link). */ + /* License attribution footer. Part of the Unsloth attribution set the image's + integrity guard verifies (Built by the Unsloth team + AGPLv3 + copyright + + source link). */ .unsloth-attrib { margin-top: 18px; text-align: center; font-size: 12px; line-height: 1.6; color: hsl(60, 8%, 58%); width: 360px; max-width: 90vw; @@ -105,7 +106,8 @@

Unsloth

{% endif %}
- Built by Unsloth. Licensed under the GNU AGPLv3.
+ Built by the Unsloth team. + Apache 2.0, AGPLv3 License Link
Copyright 2026-Present the Unsloth team.
github.com/unslothai/unsloth · diff --git a/docker/jupyter/unsloth_branding.py b/docker/jupyter/unsloth_branding.py index 7a807f53777..43e15622c76 100644 --- a/docker/jupyter/unsloth_branding.py +++ b/docker/jupyter/unsloth_branding.py @@ -34,18 +34,23 @@ # contains these same strings). # --------------------------------------------------------------------------- PRODUCT = "Unsloth Docker Studio" -SHORT_LABEL = "Built by Unsloth" +SHORT_LABEL = "Built by the Unsloth team" +# Loading-splash caption; distinct from SHORT_LABEL (see branding.ts). +SPLASH_LABEL = "Loading Unsloth Docker" COPYRIGHT = "Copyright 2026-Present the Unsloth team" -AGPL_NOTICE = "Licensed under the GNU AGPLv3" +AGPL_NOTICE = "Licensed under Apache 2.0 and the GNU AGPLv3" WEBSITE_URL = "https://unsloth.ai" +DOCS_URL = "https://unsloth.ai/docs" SOURCE_URL = "https://github.com/unslothai/unsloth" +LICENSE_URL = "https://github.com/unslothai/unsloth#license" AGPL_URL = "https://www.gnu.org/licenses/agpl-3.0.html" +APACHE_URL = "https://www.apache.org/licenses/LICENSE-2.0" # ONE plain literal, byte-identical to PHRASE in unsloth_labext/src/branding.ts. # The guard greps the built labext bundle for this exact string, so it must match # the TS literal verbatim (webpack keeps single string literals as-is). PHRASE = ( - "Unsloth Docker Studio and JupyterLab image. Built by Unsloth. " - "Licensed under the GNU AGPLv3. " + "Unsloth Docker Studio and JupyterLab image. Built by the Unsloth team. " + "Licensed under Apache 2.0 and the GNU AGPLv3. " "Source: https://github.com/unslothai/unsloth Website: https://unsloth.ai" ) diff --git a/docker/jupyter/unsloth_labext/src/about.ts b/docker/jupyter/unsloth_labext/src/about.ts index f4bc8b306ef..f07639e1c01 100644 --- a/docker/jupyter/unsloth_labext/src/about.ts +++ b/docker/jupyter/unsloth_labext/src/about.ts @@ -16,7 +16,10 @@ import { UNSLOTH_LOGO_DATA_URI } from './logo'; import { AGPL_NOTICE, AGPL_URL, + APACHE_URL, COPYRIGHT, + DOCS_URL, + LICENSE_URL, PHRASE, PRODUCT, SHORT_LABEL, @@ -39,16 +42,25 @@ function aboutBody(): Widget { el.style.padding = '4px 10px 10px'; el.style.maxWidth = '430px'; el.setAttribute('data-unsloth-attribution', PHRASE); + // The link rows sit in a left-aligned inline-block centered in the dialog, so + // the "Source:/Website:/Licenses" labels line up instead of each row centering + // independently (the previous ragged look). el.innerHTML = ` Unsloth
${PRODUCT}
${SHORT_LABEL}
-
- ${AGPL_NOTICE}.
- Source: ${SOURCE_URL}
- Website: ${WEBSITE_URL}
- License: GNU AGPLv3 +
${AGPL_NOTICE}.
+
+ + +
Unsloth Reference: ${DOCS_URL}
+
Licenses
+
+
Unsloth Studio: AGPLv3
+
Unsloth Core: Apache 2.0
+
Unsloth license: ${LICENSE_URL}
+
${COPYRIGHT}
`; diff --git a/docker/jupyter/unsloth_labext/src/branding.ts b/docker/jupyter/unsloth_labext/src/branding.ts index 9f1635714cf..67fe498d0ae 100644 --- a/docker/jupyter/unsloth_labext/src/branding.ts +++ b/docker/jupyter/unsloth_labext/src/branding.ts @@ -9,16 +9,22 @@ // pointless for an open-source image). export const PRODUCT = 'Unsloth Docker Studio'; -export const SHORT_LABEL = 'Built by Unsloth'; +export const SHORT_LABEL = 'Built by the Unsloth team'; +// Loading-splash caption. Deliberately distinct from SHORT_LABEL (which the +// About dialog + guard use): the splash says what is loading, not attribution. +export const SPLASH_LABEL = 'Loading Unsloth Docker'; export const COPYRIGHT = 'Copyright 2026-Present the Unsloth team'; -export const AGPL_NOTICE = 'Licensed under the GNU AGPLv3'; +export const AGPL_NOTICE = 'Licensed under Apache 2.0 and the GNU AGPLv3'; export const WEBSITE_URL = 'https://unsloth.ai'; +export const DOCS_URL = 'https://unsloth.ai/docs'; export const SOURCE_URL = 'https://github.com/unslothai/unsloth'; +export const LICENSE_URL = 'https://github.com/unslothai/unsloth#license'; export const AGPL_URL = 'https://www.gnu.org/licenses/agpl-3.0.html'; +export const APACHE_URL = 'https://www.apache.org/licenses/LICENSE-2.0'; // Must equal PHRASE in unsloth_branding.py (the guard greps the built bundle for // it). Kept as ONE plain literal -- not a concatenation of the constants above -- // so webpack/terser preserves the full phrase contiguously in the bundle instead // of folding it into a runtime `+` expression the guard could not grep for. export const PHRASE = - 'Unsloth Docker Studio and JupyterLab image. Built by Unsloth. Licensed under the GNU AGPLv3. Source: https://github.com/unslothai/unsloth Website: https://unsloth.ai'; + 'Unsloth Docker Studio and JupyterLab image. Built by the Unsloth team. Licensed under Apache 2.0 and the GNU AGPLv3. Source: https://github.com/unslothai/unsloth Website: https://unsloth.ai'; diff --git a/docker/jupyter/unsloth_labext/src/splash.ts b/docker/jupyter/unsloth_labext/src/splash.ts index f9a1f221d6f..8f4b3f548a0 100644 --- a/docker/jupyter/unsloth_labext/src/splash.ts +++ b/docker/jupyter/unsloth_labext/src/splash.ts @@ -10,7 +10,7 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application'; import { ISplashScreen } from '@jupyterlab/apputils'; import { DisposableDelegate, IDisposable } from '@lumino/disposable'; import { UNSLOTH_LOGO_DATA_URI } from './logo'; -import { SHORT_LABEL } from './branding'; +import { SPLASH_LABEL } from './branding'; const STYLE_ID = 'unsloth-splash-style'; const SPLASH_ID = 'unsloth-splash'; @@ -74,7 +74,7 @@ const splashPlugin: JupyterFrontEndPlugin = { const label = document.createElement('div'); label.className = 'unsloth-splash-label'; - label.textContent = SHORT_LABEL; + label.textContent = SPLASH_LABEL; overlay.appendChild(label); document.body.appendChild(overlay); diff --git a/tests/studio/test_branding_guard.py b/tests/studio/test_branding_guard.py index 372602dba8f..c968cc4797d 100644 --- a/tests/studio/test_branding_guard.py +++ b/tests/studio/test_branding_guard.py @@ -60,8 +60,9 @@ def _stage(tmp_path): (js_dir / "templates").mkdir(parents = True) (js_dir / "templates" / "login.html").write_text( - "Built by Unsloth. Licensed under the GNU AGPLv3.\n" + "Built by the Unsloth team. Apache 2.0, AGPLv3 License Link\n" "Copyright 2026-Present the Unsloth team.\n" + "https://github.com/unslothai/unsloth#license\n" "https://github.com/unslothai/unsloth\n", encoding = "utf-8", )