diff --git a/.github/workflows/notebooks-ci.yml b/.github/workflows/notebooks-ci.yml
new file mode 100644
index 0000000000..90271877ba
--- /dev/null
+++ b/.github/workflows/notebooks-ci.yml
@@ -0,0 +1,320 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
+#
+# Cross-repo notebook validator. Lives in unslothai/unsloth (this repo)
+# and inspects every notebook in unslothai/notebooks at HEAD (or the
+# ref dispatched in via repository_dispatch).
+#
+# Catches the bug classes that landed in:
+# - unslothai/notebooks#258 Colab torchao 0.10 vs peft 0.19 floor
+# - unslothai/notebooks#260 DONT_UPDATE_EXCEPTIONS coverage drift
+# - unslothai/notebooks#261 torch/torchcodec ABI; --no-deps tokenizers
+# - unslothai/notebooks#264 --no-deps transformers + Colab tokenizers drift
+# - unslothai/notebooks#221 git+ HEAD installs in install cells
+# - unslothai/notebooks commit 51b1462 template/notebook drift
+#
+# CPU-only by design. Layer 2 (api-introspect) reuses the existing
+# tests/_zoo_aggressive_cuda_spoof.py harness so `import unsloth`
+# succeeds on a GPU-less ubuntu-latest runner.
+
+name: Notebooks CI
+
+on:
+ pull_request:
+ paths:
+ - 'unsloth/**'
+ - 'scripts/notebook_validator.py'
+ - 'scripts/notebook_to_python.py'
+ - 'scripts/data/colab_pip_freeze.gpu.txt'
+ - 'scripts/data/colab_to_cpu_pin.json'
+ - 'tests/notebooks/**'
+ - 'tests/_zoo_aggressive_cuda_spoof.py'
+ - '.github/workflows/notebooks-ci.yml'
+ schedule:
+ # Daily 06:17 UTC. Catches Colab preinstall bumps (the upstream image
+ # is rebuilt roughly weekly) without us waiting on a PR. Off the
+ # :00/:30 fleet-collision spots.
+ - cron: '17 6 * * *'
+ workflow_dispatch:
+ inputs:
+ notebooks_ref:
+ description: 'unslothai/notebooks ref to lint (branch / SHA / tag)'
+ default: 'main'
+ include_smoke:
+ description: 'Also run the install-cell smoke matrix (longer)'
+ type: boolean
+ default: false
+ repository_dispatch:
+ # Fired by a tiny companion workflow on unslothai/notebooks.
+ types: [notebooks_pr_opened, notebooks_main_pushed]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+env:
+ NOTEBOOKS_REF: >-
+ ${{ github.event.inputs.notebooks_ref ||
+ github.event.client_payload.ref ||
+ 'main' }}
+
+jobs:
+ static:
+ name: static (drift + lint + exceptions)
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout unsloth (this PR)
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ with:
+ path: unsloth
+
+ - name: Checkout unslothai/notebooks @ ${{ env.NOTEBOOKS_REF }}
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ with:
+ repository: unslothai/notebooks
+ ref: ${{ env.NOTEBOOKS_REF }}
+ path: notebooks
+ fetch-depth: 0 # drift check needs git status / diff
+
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: '3.12'
+ cache: 'pip'
+
+ - name: Install validator deps
+ run: |
+ python -m pip install --upgrade pip
+ # nbformat + nbconvert come from the converter's requirements;
+ # spellchecker + huggingface_hub are imported at module top of
+ # update_all_notebooks.py.
+ pip install \
+ 'nbformat>=5.10' 'nbconvert>=7.16' 'pyspellchecker>=0.8' \
+ 'huggingface_hub>=0.34' 'tqdm>=4.66'
+
+ - name: Refresh Colab pip-freeze (best-effort; falls back to snapshot)
+ run: |
+ python unsloth/scripts/notebook_validator.py refresh-colab \
+ --out unsloth/scripts/data/colab_pip_freeze.gpu.txt \
+ || echo "::warning::refresh-colab failed; using committed snapshot"
+
+ - name: Drift check (re-run update_all_notebooks.py + git diff)
+ working-directory: ${{ github.workspace }}
+ run: |
+ python unsloth/scripts/notebook_validator.py drift \
+ --notebooks-dir notebooks
+
+ - name: Convert sanity (every nb / kaggle / original_template -> .py)
+ run: |
+ python unsloth/scripts/notebook_validator.py convert \
+ --notebooks-dir notebooks \
+ --out _converted
+
+ - name: Lint (install cells + AST scan, env-scoped)
+ run: |
+ python unsloth/scripts/notebook_validator.py lint \
+ --notebooks-dir notebooks \
+ --colab-pin unsloth/scripts/data/colab_pip_freeze.gpu.txt \
+ --no-pypi
+ # --no-pypi skips R-INST-002 (transitive resolve via PyPI metadata).
+ # Layer 1 keeps PR-time wall-clock predictable; the daily cron run
+ # below drops --no-pypi and refreshes the cache.
+
+ - name: DONT_UPDATE_EXCEPTIONS coverage
+ run: |
+ python unsloth/scripts/notebook_validator.py exceptions \
+ --notebooks-dir notebooks
+
+ static-with-pypi:
+ name: static + transitive resolve (cron / dispatch only)
+ if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ with: { path: unsloth }
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ with:
+ repository: unslothai/notebooks
+ ref: ${{ env.NOTEBOOKS_REF }}
+ path: notebooks
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with: { python-version: '3.12', cache: 'pip' }
+ - name: Install
+ run: pip install -U pip
+ - name: Refresh Colab oracle
+ run: |
+ python unsloth/scripts/notebook_validator.py refresh-colab \
+ --out unsloth/scripts/data/colab_pip_freeze.gpu.txt
+ - name: Lint with live PyPI metadata
+ run: |
+ python unsloth/scripts/notebook_validator.py lint \
+ --notebooks-dir notebooks \
+ --colab-pin unsloth/scripts/data/colab_pip_freeze.gpu.txt
+
+ api-introspect:
+ name: api surface (under CUDA spoof)
+ runs-on: ubuntu-latest
+ timeout-minutes: 12
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ with: { path: unsloth }
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ with:
+ repository: unslothai/notebooks
+ ref: ${{ env.NOTEBOOKS_REF }}
+ path: notebooks
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with: { python-version: '3.12', cache: 'pip' }
+
+ - name: Install CPU torch + pinned unsloth + trl
+ run: |
+ python -m pip install --upgrade pip
+ pip install --index-url https://download.pytorch.org/whl/cpu 'torch>=2.8,<2.11'
+ # Pin to the same versions update_all_notebooks.py installs in
+ # generated notebooks. Keep these in lockstep with PIN_TRL /
+ # PIN_TRANSFORMERS in unslothai/notebooks/update_all_notebooks.py.
+ pip install 'transformers>=4.56,<5.6' 'trl>=0.22,<0.26' 'accelerate>=1.0' \
+ 'datasets>=3.4,<5' 'peft>=0.15,<0.20' \
+ 'bitsandbytes>=0.43' 'sentencepiece' 'protobuf'
+ pip install --no-deps unsloth_zoo unsloth
+
+ - name: Convert notebooks for AST scan
+ run: |
+ python unsloth/scripts/notebook_validator.py convert \
+ --notebooks-dir notebooks --out _converted
+
+ - name: Dump unsloth + trl API surface (under CUDA spoof)
+ run: |
+ PYTHONPATH=unsloth/tests python -u - <<'PY'
+ import sys, json, inspect
+ import _zoo_aggressive_cuda_spoof as _spoof
+ _spoof.apply()
+ import unsloth
+ import trl
+ surface = {}
+ for cls_name in ("FastLanguageModel", "FastVisionModel", "FastModel"):
+ cls = getattr(unsloth, cls_name, None)
+ if cls is None:
+ continue
+ surface[cls_name] = sorted(n for n in dir(cls) if not n.startswith("_"))
+ surface["SFTConfig_kwargs"] = sorted(inspect.signature(trl.SFTConfig.__init__).parameters)
+ json.dump(surface, open("_api_surface.json", "w"), indent=2)
+ print("dumped surface for:", list(surface))
+ PY
+
+ - name: Run API rule against converted notebooks
+ run: |
+ python unsloth/scripts/notebook_validator.py api \
+ --converted-dir _converted \
+ --surface _api_surface.json
+
+ smoke-install:
+ name: smoke install (Colab-shaped venv, opt-in)
+ if: ${{ github.event.inputs.include_smoke == 'true' || github.event_name == 'schedule' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ strategy:
+ fail-fast: false
+ matrix:
+ # One representative notebook per installation_*_content template.
+ # Add rows when a new install template lands in update_all_notebooks.py.
+ notebook:
+ - 'nb/Llama3.1_(8B)-Alpaca.ipynb' # installation_content
+ - 'nb/Gemma3_(4B)-Vision.ipynb' # installation_content + vision
+ - 'nb/Llama3.1_(8B)-GRPO.ipynb' # installation_extra_grpo_content
+ - 'nb/gpt-oss-(20B)-Fine-tuning.ipynb' # installation_gpt_oss_content
+ - 'nb/Qwen3_5_(4B)_Vision.ipynb' # installation_qwen3_5_content
+ - 'nb/Nemotron-3-Nano-30B-A3B_A100.ipynb' # installation_nemotron_nano_content
+ - 'nb/Whisper.ipynb' # installation_whisper_content
+ - 'nb/Synthetic_Data_Hackathon.ipynb' # installation_synthetic_data_content
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ with: { path: unsloth }
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ with:
+ repository: unslothai/notebooks
+ ref: ${{ env.NOTEBOOKS_REF }}
+ path: notebooks
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with: { python-version: '3.12' }
+
+ - name: Seed Colab-shaped venv from pip-freeze (CPU-mapped)
+ run: |
+ # Strip cu128 local versions, route torch/torchvision to the CPU
+ # wheel index, drop CUDA-specific deps the runner can't use.
+ python -u - <<'PY' > /tmp/seed_pins.txt
+ import json, re
+ mapping = json.load(open("unsloth/scripts/data/colab_to_cpu_pin.json"))
+ rewrite = mapping["rewrite"]
+ skip = set(mapping["skip"])
+ spoof = set(mapping["module_spoof"])
+ out = []
+ for line in open("unsloth/scripts/data/colab_pip_freeze.gpu.txt"):
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ m = re.match(r"^([A-Za-z0-9._-]+)\s*==\s*(.+)$", line)
+ if not m:
+ continue
+ name, ver = m.group(1).lower(), m.group(2)
+ if name in skip:
+ continue
+ if name in spoof:
+ continue
+ if name in rewrite:
+ ver = re.sub(r"[+\-].+$", "", ver)
+ out.append(f"{name}=={ver}")
+ else:
+ ver = re.sub(r"[+\-].+$", "", ver)
+ out.append(f"{name}=={ver}")
+ print("\n".join(out))
+ PY
+ head -5 /tmp/seed_pins.txt
+ wc -l /tmp/seed_pins.txt
+
+ - name: Install Colab-shaped venv
+ run: |
+ python -m pip install --upgrade pip
+ # Best-effort: any single line that fails to resolve on CPU is
+ # tolerated; the smoke contract is "the install cell + the unsloth
+ # import works", not "the entire Colab venv reproduces."
+ while IFS= read -r spec; do
+ pip install "$spec" --index-url https://download.pytorch.org/whl/cpu \
+ --extra-index-url https://pypi.org/simple || \
+ echo "::warning::pin failed: $spec"
+ done < /tmp/seed_pins.txt
+
+ - name: Run install cell
+ run: |
+ python unsloth/scripts/notebook_validator.py convert \
+ --notebooks-dir notebooks --out _converted
+ # Take the converted .py and run the install cell only.
+ BASE="$(basename '${{ matrix.notebook }}' .ipynb | tr -d '()' | tr -c '[:alnum:]_' _)"
+ PY="_converted/${BASE}.py"
+ [ -f "$PY" ] || { echo "::error::$PY not found"; ls _converted | head; exit 1; }
+ # Truncate at the first `from unsloth import` so we run install +
+ # core imports only.
+ awk '/^from unsloth import/ { print "import sys; sys.exit(0)"; exit } { print }' "$PY" > _smoke.py
+ PYTHONPATH=unsloth/tests python -u - <<'PY'
+ import _zoo_aggressive_cuda_spoof as _s; _s.apply()
+ # Stub torchcodec for cells that import it — no CPU wheel exists.
+ import sys, types
+ if "torchcodec" not in sys.modules:
+ sys.modules["torchcodec"] = types.ModuleType("torchcodec")
+ exec(open("_smoke.py").read(), {"__name__": "__main__"})
+ PY
+
+ - name: Verify imports under spoof
+ run: |
+ PYTHONPATH=unsloth/tests python -u - <<'PY'
+ import sys, types
+ if "torchcodec" not in sys.modules:
+ sys.modules["torchcodec"] = types.ModuleType("torchcodec")
+ import _zoo_aggressive_cuda_spoof as _s; _s.apply()
+ import unsloth, peft, torch, torchao, transformers, tokenizers
+ print("OK: imports pass under CUDA spoof")
+ PY
diff --git a/.gitignore b/.gitignore
index ae6770bc07..bc7d59316d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,8 @@ __pycache__/
*.py[cod]
*.class
unsloth_compiled_cache/
+# Notebook-validator runtime PyPI metadata cache (CI repopulates).
+scripts/data/pypi_cache/
# ML artifacts (large files)
feature/
outputs/
diff --git a/scripts/data/colab_pip_freeze.gpu.txt b/scripts/data/colab_pip_freeze.gpu.txt
new file mode 100644
index 0000000000..0e24ef945d
--- /dev/null
+++ b/scripts/data/colab_pip_freeze.gpu.txt
@@ -0,0 +1,731 @@
+# Do not modify this file directly; it is generated by extract_colabx_testing_tarballs.sh via
+# $ python3 -m pip freeze
+# Be aware that this list does not necessarily reflect the current state of the
+# staging or production container, but rather the state as of the most recent
+# submitted CL where extract_colabx_testing_tarballs.sh was run.
+absl-py==1.4.0
+accelerate==1.13.0
+access==1.1.10.post3
+affine==2.4.0
+aiofiles==24.1.0
+aiohappyeyeballs==2.6.1
+aiohttp==3.13.5
+aiosignal==1.4.0
+aiosqlite==0.22.1
+alabaster==1.0.0
+albucore==0.0.24
+albumentations==2.0.8
+ale-py==0.11.2
+alembic==1.18.4
+altair==5.5.0
+annotated-doc==0.0.4
+annotated-types==0.7.0
+antlr4-python3-runtime==4.9.3
+anyio==4.13.0
+anywidget==0.9.21
+apsw==3.53.0.0
+apswutils==0.1.2
+argon2-cffi==25.1.0
+argon2-cffi-bindings==25.1.0
+array_record==0.8.3
+arrow==1.4.0
+arviz==0.22.0
+astropy==7.2.0
+astropy-iers-data==0.2026.4.20.0.58.15
+astunparse==1.6.3
+atpublic==5.1
+attrs==26.1.0
+audioread==3.1.0
+Authlib==1.6.11
+autograd==1.8.0
+babel==2.18.0
+backcall==0.2.0
+beartype==0.22.9
+beautifulsoup4==4.13.5
+betterproto==2.0.0b6
+bigframes==2.39.0
+bigquery-magics==0.14.0
+bleach==6.3.0
+blinker==1.9.0
+blis==1.3.3
+blobfile==3.2.0
+blosc2==4.1.2
+bokeh==3.8.2
+Bottleneck==1.4.2
+bqplot==0.12.45
+branca==0.8.2
+brotli==1.2.0
+CacheControl==0.14.4
+cachetools==6.2.6
+catalogue==2.0.10
+certifi==2026.4.22
+cffi==2.0.0
+chardet==5.2.0
+charset-normalizer==3.4.7
+clarabel==0.11.1
+click==8.3.3
+click-plugins==1.1.1.2
+cligj==0.7.2
+cloudpathlib==0.23.0
+cloudpickle==3.1.2
+cmake==3.31.10
+cmdstanpy==1.3.0
+colorcet==3.1.0
+colorlover==0.3.0
+community==1.0.0b1
+confection==1.3.3
+cons==0.4.7
+contourpy==1.3.3
+cramjam==2.11.0
+cryptography==43.0.3
+cucim-cu12 @ https://pypi.nvidia.com/cucim-cu12/cucim_cu12-26.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+cuda-bindings==12.9.4
+cuda-core==0.3.2
+cuda-pathfinder==1.5.3
+cuda-python==12.9.4
+cuda-toolkit==12.8.1
+cudf-cu12==26.2.1
+cudf-polars-cu12==26.2.1
+cufflinks==0.17.3
+cuml-cu12==26.2.0
+cupy-cuda12x==14.0.1
+curl_cffi==0.15.0
+cuvs-cu12 @ https://pypi.nvidia.com/cuvs-cu12/cuvs_cu12-26.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+cvxopt==1.3.2
+cvxpy==1.6.7
+cycler==0.12.1
+cyipopt==1.5.0
+cymem==2.0.13
+Cython==3.0.12
+dask==2026.1.1
+dask-cuda==26.2.0
+dask-cudf-cu12==26.2.1
+dataproc-spark-connect==1.1.0
+datasets==4.0.0
+db-dtypes==1.5.1
+dbus-python==1.2.18
+debugpy==1.8.15
+decorator==4.4.2
+defusedxml==0.7.1
+deprecation==2.1.0
+diffusers==0.37.1
+dill==0.3.8
+distributed==2026.1.1
+distributed-ucxx-cu12==0.48.0
+distro==1.9.0
+dlib==19.24.6
+dm-tree==0.1.10
+docstring_parser==0.18.0
+docutils==0.21.2
+dopamine_rl==4.1.2
+duckdb==1.3.2
+earthengine-api==1.7.22
+easydict==1.13
+editdistance==0.8.1
+eerepr==0.1.2
+einops==0.8.2
+en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl#sha256=1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85
+entrypoints==0.4
+esda==2.9.0
+et_xmlfile==2.0.0
+etils==1.14.0
+etuples==0.3.10
+Farama-Notifications==0.0.4
+fastai==2.8.7
+fastapi==0.136.1
+fastcore==1.12.42
+fastdownload==0.0.7
+fastjsonschema==2.21.2
+fastlite==0.2.4
+fastprogress==1.1.5
+fasttransform==0.0.2
+ffmpy==1.0.0
+filelock==3.29.0
+fiona==1.10.1
+firebase-admin==6.9.0
+Flask==3.1.3
+flatbuffers==25.12.19
+flax==0.11.2
+folium==0.20.0
+fonttools==4.62.1
+fqdn==1.5.1
+frozendict==2.4.7
+frozenlist==1.8.0
+fsspec==2025.3.0
+future==1.0.0
+gast==0.7.0
+gcsfs==2025.3.0
+GDAL==3.8.4
+gdown==5.2.2
+geemap==0.37.2
+geocoder==1.38.1
+geographiclib==2.1
+geopandas==1.1.3
+geopy==2.4.1
+giddy==2.3.6
+gin-config==0.5.0
+gitdb==4.0.12
+GitPython==3.1.47
+glob2==0.7
+google==3.0.0
+google-adk==1.29.0
+google-ai-generativelanguage==0.6.15
+google-api-core==2.30.3
+google-api-python-client==2.194.0
+google-auth==2.47.0
+google-auth-httplib2==0.3.1
+google-auth-oauthlib==1.3.1
+google-cloud-aiplatform==1.148.1
+google-cloud-appengine-logging==1.9.0
+google-cloud-audit-log==0.5.0
+google-cloud-bigquery==3.41.0
+google-cloud-bigquery-connection==1.21.0
+google-cloud-bigquery-storage==2.37.0
+google-cloud-bigtable==2.36.0
+google-cloud-core==2.5.1
+google-cloud-dataplex==2.18.0
+google-cloud-dataproc==5.27.0
+google-cloud-datastore==2.24.0
+google-cloud-discoveryengine==0.13.12
+google-cloud-firestore==2.27.0
+google-cloud-functions==1.23.0
+google-cloud-iam==2.22.0
+google-cloud-language==2.20.0
+google-cloud-logging==3.15.0
+google-cloud-monitoring==2.30.0
+google-cloud-pubsub==2.37.0
+google-cloud-resource-manager==1.17.0
+google-cloud-secret-manager==2.27.0
+google-cloud-spanner==3.65.0
+google-cloud-speech==2.38.0
+google-cloud-storage==3.10.1
+google-cloud-trace==1.19.0
+google-cloud-translate==3.26.0
+google-colab @ file:///colabtools/dist/google_colab-1.0.0.tar.gz
+google-crc32c==1.8.0
+google-genai==1.68.0
+google-generativeai==0.8.6
+google-pasta==0.2.0
+google-resumable-media==2.8.2
+googleapis-common-protos==1.74.0
+googledrivedownloader==1.1.0
+gradio==5.50.0
+gradio_client==1.14.0
+grain==0.2.16
+graphviz==0.21
+greenlet==3.4.0
+groovy==0.1.2
+grpc-google-iam-v1==0.14.4
+grpc-interceptor==0.15.4
+grpcio==1.80.0
+grpcio-status==1.71.2
+grpclib==0.4.9
+gspread==6.2.1
+gspread-dataframe==4.0.0
+gym==0.25.2
+gym-notices==0.1.0
+gymnasium==1.3.0
+h11==0.16.0
+h2==4.3.0
+h5netcdf==1.8.1
+h5py==3.16.0
+hdbscan==0.8.42
+hf-xet==1.4.3
+highspy==1.14.0
+holidays==0.95
+holoviews==1.22.1
+hpack==4.1.0
+html5lib==1.1
+httpcore==1.0.9
+httpimport==1.4.1
+httplib2==0.31.2
+httptools==0.7.1
+httpx==0.28.1
+httpx-sse==0.4.3
+huggingface_hub==1.11.0
+humanize==4.15.0
+hyperframe==6.1.0
+hyperopt==0.2.7
+ibis-framework==9.5.0
+idna==3.13
+ImageIO==2.37.3
+imageio-ffmpeg==0.6.0
+imagesize==2.0.0
+imbalanced-learn==0.14.1
+immutabledict==4.3.1
+importlib_metadata==8.7.1
+importlib_resources==7.1.0
+imutils==0.5.4
+inequality==1.1.2
+inflect==7.5.0
+iniconfig==2.3.0
+intel-cmplr-lib-ur==2025.3.3
+intel-openmp==2025.3.3
+ipyevents==2.0.4
+ipyfilechooser==0.6.0
+ipykernel==6.17.1
+ipyleaflet==0.20.0
+ipyparallel==8.8.0
+ipython==7.34.0
+ipython-genutils==0.2.0
+ipython-sql==0.5.0
+ipywidgets==7.7.1
+isoduration==20.11.0
+itsdangerous==2.2.0
+jaraco.classes==3.4.0
+jaraco.context==6.1.2
+jaraco.functools==4.4.0
+jax==0.7.2
+jax-cuda12-pjrt==0.7.2
+jax-cuda12-plugin==0.7.2
+jaxlib==0.7.2
+jeepney==0.9.0
+jieba==0.42.1
+Jinja2==3.1.6
+jiter==0.14.0
+joblib==1.5.3
+jsonpatch==1.33
+jsonpickle==4.1.1
+jsonpointer==3.1.1
+jsonschema==4.26.0
+jsonschema-specifications==2025.9.1
+jupyter-console==6.6.3
+jupyter-events==0.12.1
+jupyter-leaflet==0.20.0
+jupyter_client==7.4.9
+jupyter_core==5.9.1
+jupyter_kernel_gateway @ git+https://github.com/googlecolab/kernel_gateway@b134e9945df25c2dcb98ade9129399be10788671
+jupyter_server==2.14.0
+jupyter_server_terminals==0.5.4
+jupyterlab_pygments==0.3.0
+jupyterlab_widgets==3.0.16
+jupytext==1.19.1
+kaggle==2.0.2
+kagglehub==1.0.0
+kagglesdk==0.1.20
+keras==3.13.2
+keras-hub==0.26.0
+keras-nlp==0.26.0
+keyring==25.7.0
+keyrings.google-artifactregistry-auth==1.1.2
+kiwisolver==1.5.0
+langchain==1.2.15
+langchain-core==1.3.1
+langgraph==1.1.9
+langgraph-checkpoint==4.0.2
+langgraph-prebuilt==1.0.10
+langgraph-sdk==0.3.13
+langsmith==0.7.34
+lark==1.3.1
+launchpadlib==1.10.16
+lazr.restfulclient==0.14.4
+lazr.uri==1.0.6
+lazy-loader==0.5
+libclang==18.1.1
+libcudf-cu12==26.2.1
+libcugraph-cu12==26.2.0
+libcuml-cu12==26.2.0
+libcuvs-cu12==26.2.0
+libkvikio-cu12==26.2.0
+libpysal==4.14.1
+libraft-cu12==26.2.0
+librmm-cu12==26.2.0
+librosa==0.11.0
+libucx-cu12==1.19.0
+libucxx-cu12==0.48.0
+lightgbm==4.6.0
+linkify-it-py==2.1.0
+llvmlite==0.43.0
+locket==1.0.0
+logical-unification==0.4.7
+lxml==6.1.0
+Mako==1.3.11
+mapclassify==2.10.0
+Markdown==3.10.2
+markdown-it-py==4.0.0
+MarkupSafe==3.0.3
+matplotlib==3.10.0
+matplotlib-inline==0.2.1
+matplotlib-venn==1.1.2
+mcp==1.27.0
+mdit-py-plugins==0.5.0
+mdurl==0.1.2
+mgwr==2.2.1
+miniKanren==1.0.5
+missingno==0.5.2
+mistune==3.2.0
+mizani==0.13.5
+mkl==2025.3.1
+ml_dtypes==0.5.4
+mlxtend==0.23.4
+mmh3==5.2.1
+momepy==0.11.0
+more-itertools==10.8.0
+moviepy==1.0.3
+mpmath==1.3.0
+msgpack==1.1.2
+multidict==6.7.1
+multipledispatch==1.0.0
+multiprocess==0.70.16
+multitasking==0.0.13
+murmurhash==1.0.15
+music21==9.9.1
+namex==0.1.0
+narwhals==2.20.0
+natsort==8.4.0
+nbclassic==1.3.3
+nbclient==0.10.4
+nbconvert==7.17.1
+nbformat==5.10.4
+ndindex==1.10.1
+nest-asyncio==1.6.0
+networkx==3.6.1
+nibabel==5.4.2
+nltk==3.9.1
+notebook==6.5.7
+notebook_shim==0.2.4
+numba==0.60.0
+numba-cuda==0.22.2
+numexpr==2.14.1
+numpy==2.0.2
+nvidia-cublas-cu12==12.8.4.1
+nvidia-cuda-cccl-cu12==12.9.27
+nvidia-cuda-cupti-cu12==12.8.90
+nvidia-cuda-nvcc-cu12==12.8.93
+nvidia-cuda-nvrtc-cu12==12.8.93
+nvidia-cuda-runtime-cu12==12.8.90
+nvidia-cudnn-cu12==9.10.2.21
+nvidia-cufft-cu12==11.3.3.83
+nvidia-cufile-cu12==1.13.1.3
+nvidia-curand-cu12==10.3.9.90
+nvidia-cusolver-cu12==11.7.3.90
+nvidia-cusparse-cu12==12.5.8.93
+nvidia-cusparselt-cu12==0.7.1
+nvidia-libnvcomp-cu12==5.1.0.21
+nvidia-ml-py==13.595.45
+nvidia-nccl-cu12==2.27.5
+nvidia-nvimgcodec-cu12==0.7.0.11
+nvidia-nvjitlink-cu12==12.8.93
+nvidia-nvshmem-cu12==3.4.5
+nvidia-nvtx-cu12==12.8.90
+nvtx==0.2.15
+nx-cugraph-cu12 @ https://pypi.nvidia.com/nx-cugraph-cu12/nx_cugraph_cu12-26.2.0-py3-none-any.whl
+oauth2client==4.1.3
+oauthlib==3.3.1
+omegaconf==2.3.0
+onemkl-license==2025.3.1
+openai==2.32.0
+opencv-contrib-python==4.13.0.92
+opencv-python==4.13.0.92
+opencv-python-headless==4.13.0.92
+openpyxl==3.1.5
+opentelemetry-api==1.38.0
+opentelemetry-exporter-gcp-logging==1.11.0a0
+opentelemetry-exporter-gcp-monitoring==1.11.0a0
+opentelemetry-exporter-gcp-trace==1.11.0
+opentelemetry-exporter-otlp-proto-common==1.38.0
+opentelemetry-exporter-otlp-proto-http==1.38.0
+opentelemetry-proto==1.38.0
+opentelemetry-resourcedetector-gcp==1.11.0a0
+opentelemetry-sdk==1.38.0
+opentelemetry-semantic-conventions==0.59b0
+opt_einsum==3.4.0
+optax==0.2.8
+optree==0.19.0
+orbax-checkpoint==0.11.36
+orjson==3.11.8
+ormsgpack==1.12.2
+osqp==1.1.1
+overrides==7.7.0
+packaging==26.1
+pandas==2.2.2
+pandas-datareader==0.10.0
+pandas-gbq==0.30.0
+pandas-stubs==2.2.2.240909
+pandocfilters==1.5.1
+panel==1.8.10
+param==2.3.3
+parso==0.8.6
+parsy==2.2
+partd==1.4.2
+patsy==1.0.2
+peewee==4.0.5
+peft==0.19.1
+pexpect==4.9.0
+pickleshare==0.7.5
+pillow==11.3.0
+pip==24.1.2
+platformdirs==4.9.6
+plotly==5.24.1
+plotnine==0.14.5
+pluggy==1.6.0
+plum-dispatch==2.8.0
+pointpats==2.5.5
+polars==1.35.2
+polars-runtime-32==1.35.2
+pooch==1.9.0
+portpicker==1.5.2
+preshed==3.0.13
+prettytable==3.17.0
+proglog==0.1.12
+progressbar2==4.5.0
+prometheus_client==0.25.0
+promise==2.3
+prompt_toolkit==3.0.52
+propcache==0.4.1
+prophet==1.3.0
+proto-plus==1.27.2
+protobuf==5.29.6
+psutil==5.9.5
+psycopg2==2.9.12
+psygnal==0.15.1
+ptyprocess==0.7.0
+PuLP==3.3.0
+py-cpuinfo==9.0.0
+py4j==0.10.9.9
+pyarrow==18.1.0
+pyasn1==0.6.3
+pyasn1_modules==0.4.2
+pycairo==1.29.0
+pycocotools==2.0.11
+pycparser==3.0
+pycryptodomex==3.23.0
+pydantic==2.12.3
+pydantic-settings==2.14.0
+pydantic_core==2.41.4
+pydata-google-auth==1.9.1
+pydot==4.0.1
+pydotplus==2.0.2
+PyDrive2==1.21.3
+pydub==0.25.1
+pyerfa==2.0.1.5
+pygame==2.6.1
+pygit2==1.19.2
+Pygments==2.20.0
+PyGObject==3.48.2
+pyiceberg==0.11.1
+PyJWT==2.12.1
+pylibcudf-cu12==26.2.1
+pylibcugraph-cu12==26.2.0
+pylibraft-cu12==26.2.0
+pymc==5.28.4
+pynndescent==0.6.0
+pyogrio==0.12.1
+pyomo==6.10.0
+PyOpenGL==3.1.10
+pyOpenSSL==24.2.1
+pyparsing==3.3.2
+pyperclip==1.11.0
+pyproj==3.7.2
+pyroaring==1.0.4
+pysal==25.7
+pyshp==3.0.3
+PySocks==1.7.1
+pyspark==4.0.2
+pytensor==2.38.2
+pytest==8.4.2
+python-apt==0.0.0
+python-box==7.4.1
+python-dateutil==2.9.0.post0
+python-dotenv==1.2.2
+python-fasthtml==0.12.50
+python-json-logger==4.1.0
+python-louvain==0.16
+python-multipart==0.0.26
+python-slugify==8.0.4
+python-snappy==0.7.3
+python-utils==3.9.1
+pytz==2025.2
+pyviz_comms==3.0.6
+PyWavelets==1.9.0
+PyYAML==6.0.3
+pyzmq==26.2.1
+quantecon==0.11.2
+raft-dask-cu12==26.2.0
+rapids-dask-dependency==26.2.0
+rapids-logger==0.2.3
+rasterio==1.5.0
+rasterstats==0.20.0
+ratelim==0.1.6
+referencing==0.37.0
+regex==2025.11.3
+requests==2.32.4
+requests-oauthlib==2.0.0
+requests-toolbelt==1.0.0
+requirements-parser==0.9.0
+rfc3339-validator==0.1.4
+rfc3986-validator==0.1.1
+rfc3987-syntax==1.1.0
+rich==13.9.4
+rmm-cu12==26.2.0
+roman-numerals==4.1.0
+roman-numerals-py==4.1.0
+rpds-py==0.30.0
+rpy2==3.5.17
+rsa==4.9.1
+rtree==1.4.1
+ruff==0.15.11
+safehttpx==0.1.7
+safetensors==0.7.0
+scikit-image==0.25.2
+scikit-learn==1.6.1
+scipy==1.16.3
+scooby==0.11.2
+scs==3.2.11
+seaborn==0.13.2
+SecretStorage==3.5.0
+segregation==2.5.4
+semantic-version==2.10.0
+Send2Trash==2.1.0
+sentence-transformers==5.4.1
+sentencepiece==0.2.1
+sentry-sdk==2.58.0
+setuptools==75.2.0
+shap==0.51.0
+shapely==2.1.2
+shellingham==1.5.4
+simple-parsing==0.1.8
+simplejson==4.1.0
+simsimd==6.5.16
+six==1.17.0
+sklearn-compat==0.1.5
+sklearn-pandas==2.2.0
+slicer==0.0.8
+smart_open==7.6.0
+smmap==5.0.3
+sniffio==1.3.1
+snowballstemmer==3.0.1
+sortedcontainers==2.4.0
+soundfile==0.13.1
+soupsieve==2.8.3
+soxr==1.0.0
+spacy==3.8.14
+spacy-legacy==3.0.12
+spacy-loggers==1.0.5
+spaghetti==1.7.6
+spanner-graph-notebook==1.1.10
+spglm==1.1.0
+Sphinx==8.2.3
+sphinxcontrib-applehelp==2.0.0
+sphinxcontrib-devhelp==2.0.0
+sphinxcontrib-htmlhelp==2.1.0
+sphinxcontrib-jsmath==1.0.1
+sphinxcontrib-qthelp==2.0.0
+sphinxcontrib-serializinghtml==2.0.0
+spint==1.0.7
+splot==1.1.7
+spopt==0.7.0
+spreg==1.9.0
+SQLAlchemy==2.0.49
+sqlalchemy-spanner==1.17.3
+sqlglot==25.20.2
+sqlparse==0.5.5
+srsly==2.5.3
+sse-starlette==3.3.4
+stanio==0.5.1
+starlette==0.52.1
+statsmodels==0.14.6
+strictyaml==1.7.3
+stringzilla==4.6.0
+stumpy==1.13.0
+sympy==1.14.0
+tables==3.10.2
+tabulate==0.9.0
+tbb==2022.3.1
+tblib==3.2.2
+tcmlib==1.4.1
+tenacity==9.1.4
+tensorboard==2.20.0
+tensorboard-data-server==0.7.2
+tensorflow==2.20.0
+tensorflow-datasets==4.9.9
+tensorflow-hub==0.16.1
+tensorflow-metadata==1.17.3
+tensorflow-probability==0.25.0
+tensorflow-text==2.20.1
+tensorstore==0.1.82
+termcolor==3.3.0
+terminado==0.18.1
+text-unidecode==1.3
+textblob==0.19.0
+tf-slim==1.1.0
+tf_keras==2.20.0
+thinc==8.3.13
+threadpoolctl==3.6.0
+tifffile==2026.4.11
+tiktoken==0.12.0
+timm==1.0.26
+tinycss2==1.4.0
+tobler==0.14.0
+tokenizers==0.22.2
+toml==0.10.2
+tomlkit==0.13.3
+toolz==0.12.1
+torch==2.10.0+cu128
+torchao==0.10.0
+torchaudio==2.10.0+cu128
+torchcodec==0.10.0+cu128
+torchdata==0.11.0
+torchsummary==1.5.1
+torchtune==0.6.1
+torchvision==0.25.0+cu128
+tornado==6.5.1
+tqdm==4.67.3
+traitlets==5.7.1
+traittypes==0.2.3
+transformers==5.0.0
+treelite==4.7.0
+treescope==0.1.10
+triton==3.6.0
+tsfresh==0.21.1
+tweepy==4.16.0
+typeguard==4.5.1
+typer==0.24.2
+typer-slim==0.24.0
+types-pytz==2026.1.1.20260408
+types-setuptools==82.0.0.20260408
+typing-inspection==0.4.2
+typing_extensions==4.15.0
+tzdata==2026.1
+tzlocal==5.3.1
+uc-micro-py==2.0.0
+ucxx-cu12==0.48.0
+umap-learn==0.5.12
+umf==1.0.3
+uri-template==1.3.0
+uritemplate==4.2.0
+urllib3==2.5.0
+uuid_utils==0.14.1
+uvicorn==0.46.0
+uvloop==0.22.1
+vega-datasets==0.9.0
+wadllib==1.3.6
+wandb==0.26.1
+wasabi==1.1.3
+watchdog==6.0.0
+watchfiles==1.1.1
+wcwidth==0.6.0
+weasel==1.0.0
+webcolors==25.10.0
+webencodings==0.5.1
+websocket-client==1.9.0
+websockets==15.0.1
+Werkzeug==3.1.8
+wheel==0.47.0
+widgetsnbextension==3.6.10
+wordcloud==1.9.6
+wrapt==2.1.2
+xarray==2025.12.0
+xarray-einstats==0.10.0
+xgboost==3.2.0
+xlrd==2.0.2
+xxhash==3.6.0
+xyzservices==2026.3.0
+yarl==1.23.0
+ydf==0.15.0
+ydf_tf==2.20.0
+yellowbrick==1.5
+yfinance==0.2.66
+zict==3.0.0
+zipp==3.23.1
+zstandard==0.25.0
diff --git a/scripts/data/colab_to_cpu_pin.json b/scripts/data/colab_to_cpu_pin.json
new file mode 100644
index 0000000000..51128b2ffb
--- /dev/null
+++ b/scripts/data/colab_to_cpu_pin.json
@@ -0,0 +1,36 @@
+{
+ "_comment": "Maps Colab GPU runtime pinned wheels to CPU equivalents for ubuntu-latest CI smoke jobs. The Colab GPU image ships +cu128 builds that won't install on a CPU-only runner; this map either rewrites the spec to a CPU wheel from https://download.pytorch.org/whl/cpu or falls back to module-spoof for packages with no CPU build.",
+ "rewrite": {
+ "torch": {
+ "from_local_version": "+cu128",
+ "to_index_url": "https://download.pytorch.org/whl/cpu"
+ },
+ "torchvision": {
+ "from_local_version": "+cu128",
+ "to_index_url": "https://download.pytorch.org/whl/cpu"
+ },
+ "torchaudio": {
+ "from_local_version": "+cu128",
+ "to_index_url": "https://download.pytorch.org/whl/cpu"
+ }
+ },
+ "module_spoof": {
+ "torchcodec": "no CPU wheel published; smoke job sys.modules-stubs torchcodec before importing unsloth"
+ },
+ "skip": [
+ "nvidia-cublas-cu12",
+ "nvidia-cuda-cupti-cu12",
+ "nvidia-cuda-nvrtc-cu12",
+ "nvidia-cuda-runtime-cu12",
+ "nvidia-cudnn-cu12",
+ "nvidia-cufft-cu12",
+ "nvidia-curand-cu12",
+ "nvidia-cusolver-cu12",
+ "nvidia-cusparse-cu12",
+ "nvidia-cusparselt-cu12",
+ "nvidia-nccl-cu12",
+ "nvidia-nvjitlink-cu12",
+ "nvidia-nvtx-cu12",
+ "triton"
+ ]
+}
diff --git a/scripts/notebook_to_python.py b/scripts/notebook_to_python.py
new file mode 100644
index 0000000000..7bfd54a99b
--- /dev/null
+++ b/scripts/notebook_to_python.py
@@ -0,0 +1,292 @@
+#!/usr/bin/env python
+# coding: utf-8
+"""
+Convert Jupyter notebooks (.ipynb) to executable Python scripts (.py).
+
+Converts IPython magics to plain Python:
+ !command -> subprocess.run('command', shell=True)
+ %cd path -> os.chdir('path')
+ %env VAR=value -> os.environ['VAR'] = 'value'
+ %%file filename -> with open('filename', 'w') as f: f.write(...)
+ %%capture -> (skipped)
+ /content/... -> _WORKING_DIR + /...
+"""
+
+import nbformat
+import re
+import sys
+import os
+import urllib.request
+import urllib.parse
+from pathlib import Path
+
+
+def needs_fstring(cmd: str) -> bool:
+ """Check if command has Python variable interpolation like {var_name}."""
+ pattern = r"(? str:
+ """Convert GitHub blob URL to raw URL."""
+ # https://github.com/user/repo/blob/branch/path -> https://raw.githubusercontent.com/user/repo/branch/path
+ if "github.com" in url and "/blob/" in url:
+ url = url.replace("github.com", "raw.githubusercontent.com")
+ url = url.replace("/blob/", "/")
+ return url
+
+
+def download_notebook(url: str) -> tuple[str, str]:
+ """Download notebook from URL. Returns (content, filename)."""
+ # Convert blob URL to raw if needed
+ raw_url = github_blob_to_raw(url)
+
+ # Extract filename from URL
+ parsed = urllib.parse.urlparse(raw_url)
+ filename = os.path.basename(urllib.parse.unquote(parsed.path))
+
+ # Download
+ print(f"Downloading {url}...")
+ with urllib.request.urlopen(raw_url, timeout = 60) as response:
+ content = response.read().decode("utf-8")
+
+ return content, filename
+
+
+def is_url(path: str) -> bool:
+ """Check if path is a URL."""
+ return path.startswith("http://") or path.startswith("https://")
+
+
+def replace_colab_paths(source: str) -> str:
+ """Replace Colab-specific /content/ paths with current working directory."""
+ # Replace /content/ with f-string using _WORKING_DIR
+ source = source.replace('"/content/', 'f"{_WORKING_DIR}/')
+ source = source.replace("'/content/", "f'{_WORKING_DIR}/")
+ return source
+
+
+def convert_cell_to_python(source: str) -> str:
+ """Convert a cell's IPython magics to plain Python."""
+ lines = source.split("\n")
+ result = []
+ i = 0
+
+ while i < len(lines):
+ line = lines[i]
+ stripped = line.strip()
+ indent = line[: len(line) - len(line.lstrip())]
+
+ # Skip %%capture
+ if stripped.startswith("%%capture"):
+ i += 1
+ continue
+
+ # Handle %%file magic
+ if stripped.startswith("%%file "):
+ filename = stripped[7:].strip()
+ file_lines = []
+ i += 1
+ while i < len(lines):
+ file_lines.append(lines[i])
+ i += 1
+ file_content = "\n".join(file_lines)
+ file_content = file_content.replace('"""', r"\"\"\"")
+ result.append(f'{indent}with open({filename!r}, "w") as _f:')
+ result.append(f'{indent} _f.write("""{file_content}""")')
+ continue
+
+ # Handle ! shell commands
+ if stripped.startswith("!"):
+ cmd_lines = [stripped[1:]]
+ while cmd_lines[-1].rstrip().endswith("\\") and i + 1 < len(lines):
+ i += 1
+ cmd_lines.append(lines[i].strip())
+ full_cmd = "\n".join(cmd_lines)
+
+ f_prefix = "f" if needs_fstring(full_cmd) else ""
+ if "\n" in full_cmd:
+ escaped_cmd = full_cmd.replace('"""', r"\"\"\"")
+ if escaped_cmd.rstrip().endswith('"'):
+ escaped_cmd = escaped_cmd.rstrip() + " "
+ result.append(
+ f'{indent}subprocess.run({f_prefix}"""{escaped_cmd}""", shell=True)'
+ )
+ else:
+ result.append(
+ f"{indent}subprocess.run({f_prefix}{full_cmd!r}, shell=True)"
+ )
+
+ # %cd path -> os.chdir(path)
+ elif stripped.startswith("%cd "):
+ path = stripped[4:].strip()
+ result.append(f"{indent}os.chdir({path!r})")
+
+ # %env VAR=value
+ elif stripped.startswith("%env ") and "=" in stripped:
+ match = re.match(r"%env\s+(\w+)=(.+)", stripped)
+ if match:
+ var, val = match.groups()
+ result.append(f"{indent}os.environ[{var!r}] = {val!r}")
+
+ # %env VAR
+ elif stripped.startswith("%env "):
+ var = stripped[5:].strip()
+ result.append(f"{indent}os.environ.get({var!r})")
+
+ # %pwd
+ elif stripped == "%pwd":
+ result.append(f"{indent}os.getcwd()")
+
+ else:
+ result.append(line)
+
+ i += 1
+
+ return "\n".join(result)
+
+
+def convert_notebook(notebook_content: str, source_name: str = "notebook") -> str:
+ """Convert notebook JSON content to Python script."""
+ # Parse notebook
+ if isinstance(notebook_content, str):
+ notebook = nbformat.reads(notebook_content, as_version = 4)
+ else:
+ notebook = notebook_content
+
+ lines = [
+ "#!/usr/bin/env python",
+ "# coding: utf-8",
+ f"# Converted from: {source_name}",
+ "",
+ "import subprocess",
+ "import os",
+ "import sys",
+ "import re",
+ "",
+ "# Capture original packages before any installs",
+ "_original_packages = subprocess.run(",
+ " [sys.executable, '-m', 'pip', 'freeze'],",
+ " capture_output=True, text=True",
+ ").stdout",
+ "",
+ "# Working directory (replaces Colab's /content/)",
+ "_WORKING_DIR = os.getcwd()",
+ "",
+ ]
+
+ for cell in notebook.cells:
+ source = cell.source.strip()
+ if not source:
+ continue
+
+ if cell.cell_type == "code":
+ converted = convert_cell_to_python(source)
+ converted = replace_colab_paths(converted)
+ lines.append(converted)
+ lines.append("")
+
+ elif cell.cell_type == "markdown":
+ for line in source.split("\n"):
+ lines.append(f"# {line}")
+ lines.append("")
+
+ # Add package restoration at the end
+ lines.extend(
+ [
+ "",
+ "# Restore original packages (install one by one, skip failures)",
+ "for _pkg in _original_packages.strip().split('\\n'):",
+ " if _pkg:",
+ " subprocess.run([sys.executable, '-m', 'pip', 'install', _pkg, '-q'],",
+ " stderr=subprocess.DEVNULL)",
+ "",
+ ]
+ )
+
+ return "\n".join(lines)
+
+
+def convert_notebook_to_script(source: str, output_dir: str | None = None):
+ """
+ Convert a notebook to Python script.
+
+ Args:
+ source: Local file path or URL to notebook
+ output_dir: Output directory (optional, defaults to current directory)
+ """
+ if is_url(source):
+ content, filename = download_notebook(source)
+ source_name = source
+ else:
+ filename = os.path.basename(source)
+ with open(source, "r", encoding = "utf-8") as f:
+ content = f.read()
+ source_name = source
+
+ # Generate output filename
+ output_filename = filename.replace(".ipynb", ".py")
+ # Clean up filename
+ output_filename = (
+ output_filename.replace("(", "").replace(")", "").replace("-", "_")
+ )
+
+ # Add output directory if specified
+ if output_dir:
+ output_path = os.path.join(output_dir, output_filename)
+ else:
+ output_path = output_filename
+
+ # Convert
+ script = convert_notebook(content, source_name)
+
+ # Write output
+ with open(output_path, "w", encoding = "utf-8") as f:
+ f.write(script)
+
+ print(f"Converted {source} -> {output_path}")
+ return output_path
+
+
+def main():
+ import argparse
+
+ class Formatter(
+ argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter
+ ):
+ pass
+
+ parser = argparse.ArgumentParser(
+ description = __doc__,
+ formatter_class = Formatter,
+ epilog = """
+Examples:
+ python notebook_to_python.py notebook.ipynb
+ python notebook_to_python.py -o scripts/ notebook1.ipynb notebook2.ipynb
+ python notebook_to_python.py --output ./converted https://github.com/user/repo/blob/main/notebook.ipynb
+ python notebook_to_python.py https://github.com/unslothai/notebooks/blob/main/nb/Oute_TTS_(1B).ipynb
+""",
+ )
+ parser.add_argument(
+ "notebooks", nargs = "+", help = "Notebook files or URLs to convert."
+ )
+ parser.add_argument(
+ "-o", "--output", dest = "output_dir", default = ".", help = "Output directory."
+ )
+
+ args = parser.parse_args()
+
+ # Create output directory if needed
+ os.makedirs(args.output_dir, exist_ok = True)
+
+ for source in args.notebooks:
+ try:
+ convert_notebook_to_script(
+ source, output_dir = args.output_dir if args.output_dir != "." else None
+ )
+ except Exception as e:
+ print(f"ERROR converting {source}: {e}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/notebook_validator.py b/scripts/notebook_validator.py
new file mode 100644
index 0000000000..dffd40e6ba
--- /dev/null
+++ b/scripts/notebook_validator.py
@@ -0,0 +1,1148 @@
+#!/usr/bin/env python3
+# coding: utf-8
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team.
+"""
+Static + lightweight-dynamic validator for unslothai/notebooks.
+
+Built to catch the bug classes that landed in (at minimum):
+- unslothai/notebooks#258 (Colab torchao 0.10 vs peft 0.19 floor)
+- unslothai/notebooks#260 (DONT_UPDATE_EXCEPTIONS coverage drift)
+- unslothai/notebooks#261 (torch/torchcodec ABI; --no-deps tokenizers)
+- unslothai/notebooks#264 (transformers/tokenizers window with --no-deps)
+- unslothai/notebooks#221 (removed unsloth APIs in user cells, git+ install)
+- unslothai/notebooks commit 51b1462 (template/notebook drift)
+
+CPU-only by design: never imports torch / unsloth at module load. The
+api subcommand introspects unsloth under the existing
+tests/_zoo_aggressive_cuda_spoof.py harness (PR #5312) so it works on
+ubuntu-latest without a GPU.
+
+Usage:
+ python scripts/notebook_validator.py drift --notebooks-dir
+ python scripts/notebook_validator.py convert --notebooks-dir --out _converted
+ python scripts/notebook_validator.py lint --notebooks-dir [--colab-pin ]
+ python scripts/notebook_validator.py exceptions --notebooks-dir
+ python scripts/notebook_validator.py api --converted-dir _converted --surface _api_surface.json
+ python scripts/notebook_validator.py all --notebooks-dir
+ python scripts/notebook_validator.py refresh-colab --out scripts/data/colab_pip_freeze.gpu.txt
+"""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import dataclasses
+import json
+import os
+import pathlib
+import re
+import shlex
+import subprocess
+import sys
+import textwrap
+import time
+import urllib.error
+import urllib.request
+from typing import Any, Iterable, Iterator
+
+HERE = pathlib.Path(__file__).resolve().parent
+DATA_DIR = HERE / "data"
+PYPI_CACHE_DIR = DATA_DIR / "pypi_cache"
+
+COLAB_PIP_FREEZE_URL = (
+ "https://raw.githubusercontent.com/googlecolab/backend-info/main/pip-freeze.gpu.txt"
+)
+COLAB_FALLBACK_FILE = DATA_DIR / "colab_pip_freeze.gpu.txt"
+
+# ----- Compat tables. PRs add rows as new releases land. ----- #
+
+# torch.minor -> set of compatible torchcodec.minor strings.
+# Source: pytorch/torchcodec compatibility matrix on its README.
+TORCH_TORCHCODEC: dict[str, set[str]] = {
+ "2.10": {"0.10"},
+ "2.9": {"0.7", "0.8", "0.9"},
+ "2.8": {"0.6"},
+ "2.7": {"0.3", "0.4", "0.5"},
+ "2.6": {"0.2", "0.3"},
+ "2.5": {"0.1", "0.2"},
+}
+
+# When peft >= trigger is on the resolved set, torchao >= floor must also be.
+PEFT_TORCHAO_FLOOR: list[dict[str, str]] = [
+ {"trigger_peft": "0.19", "torchao_floor": "0.16.0"},
+]
+
+# git+ allowlist: install lines that legitimately fetch from GitHub. Anything
+# else flags R-INST-001.
+GIT_PLUS_ALLOWLIST = (
+ "github.com/SparkAudio/Spark-TTS",
+ "github.com/state-spaces/mamba",
+ "github.com/Dao-AILab/causal-conv1d",
+ "github.com/unslothai/unsloth-zoo",
+ "github.com/unslothai/unsloth",
+)
+
+# ----- Findings ----- #
+
+
+@dataclasses.dataclass
+class Finding:
+ rule: str
+ file: str
+ cell: int | None = None
+ line: int | None = None
+ severity: str = "error" # error | warning
+ message: str = ""
+ hint: str = ""
+
+ def to_dict(self) -> dict[str, Any]:
+ return dataclasses.asdict(self)
+
+
+# ----- Notebook walking ----- #
+
+
+def iter_notebooks(
+ notebooks_dir: pathlib.Path, include_templates: bool = False
+) -> Iterator[pathlib.Path]:
+ """Yield user-facing .ipynb files under nb/ and kaggle/. Pass
+ include_templates=True to also walk original_template/ (used by the
+ convert subcommand which doesn't lint install cells)."""
+ subs = ("nb", "kaggle")
+ if include_templates:
+ subs = ("nb", "kaggle", "original_template")
+ candidates = []
+ for sub in subs:
+ d = notebooks_dir / sub
+ if d.is_dir():
+ for p in sorted(d.glob("*.ipynb")):
+ candidates.append(p)
+ seen = set()
+ for p in candidates:
+ if p.resolve() in seen:
+ continue
+ seen.add(p.resolve())
+ yield p
+
+
+def load_notebook(path: pathlib.Path) -> dict[str, Any]:
+ return json.loads(path.read_text(encoding = "utf-8"))
+
+
+def cell_source(cell: dict[str, Any]) -> str:
+ src = cell.get("source", "")
+ if isinstance(src, list):
+ return "".join(src)
+ return src
+
+
+def code_cells(nb: dict[str, Any]) -> list[tuple[int, str]]:
+ out = []
+ for i, c in enumerate(nb.get("cells", [])):
+ if c.get("cell_type") == "code":
+ out.append((i, cell_source(c)))
+ return out
+
+
+def install_cells(nb: dict[str, Any]) -> list[tuple[int, str]]:
+ """Heuristic: any code cell that contains a `pip install`, `pip uninstall`
+ or `uv pip install` shell command, or a top-line `%%capture` magic."""
+ out = []
+ for i, src in code_cells(nb):
+ first = src.lstrip().splitlines()[:1]
+ if first and first[0].strip().startswith("%%capture"):
+ out.append((i, src))
+ continue
+ if re.search(
+ r"^[ \t]*!\s*(uv\s+)?pip\s+(install|uninstall)\b", src, re.MULTILINE
+ ):
+ out.append((i, src))
+ return out
+
+
+# Notebook target environment. The Colab oracle (pip-freeze.gpu.txt) only
+# applies to notebooks that actually run on Colab; AMD-Dev-Cloud,
+# Kaggle, HuggingFace-Course, and DGX-Spark notebooks have their own
+# preinstalled environments and the Colab-vs-cell rules are not
+# applicable to them.
+def target_environment(notebook_name: str) -> str:
+ parts = pathlib.PurePath(notebook_name).parts
+ base = parts[-1] if parts else notebook_name
+ parent = parts[-2] if len(parts) >= 2 else ""
+ if parent == "kaggle" or base.startswith("Kaggle-"):
+ return "kaggle"
+ if base.startswith("AMD-") or "_AMD_" in base:
+ return "amd"
+ if base.startswith("HuggingFace Course-") or base.startswith("HuggingFace_Course-"):
+ return "colab" # HF Course notebooks still run on Colab.
+ if "DGX_Spark" in base:
+ return "dgx_spark"
+ return "colab"
+
+
+# ----- Pip-freeze parsing ----- #
+
+PINNED_RE = re.compile(r"^\s*([A-Za-z0-9._-]+)\s*==\s*([^\s;#]+)")
+
+
+def parse_pip_freeze(path: pathlib.Path) -> dict[str, str]:
+ """Return {name_lower: version_str_with_local_version}."""
+ out: dict[str, str] = {}
+ if not path.is_file():
+ return out
+ for line in path.read_text(encoding = "utf-8").splitlines():
+ if not line.strip() or line.startswith("#"):
+ continue
+ m = PINNED_RE.match(line)
+ if m:
+ out[m.group(1).lower()] = m.group(2)
+ return out
+
+
+def normalise_version(v: str) -> str:
+ """Strip +cu128 / +cpu / -dev local-version metadata."""
+ return re.split(r"[+\-]", v, maxsplit = 1)[0]
+
+
+def version_minor(v: str) -> str:
+ parts = normalise_version(v).split(".")
+ return ".".join(parts[:2]) if len(parts) >= 2 else parts[0]
+
+
+def cmp_versions(a: str, b: str) -> int:
+ """Return -1/0/+1. Compares dotted numeric components only."""
+
+ def to_tuple(v: str) -> tuple[int, ...]:
+ return tuple(int(x) for x in re.findall(r"\d+", normalise_version(v)))
+
+ ta, tb = to_tuple(a), to_tuple(b)
+ if ta < tb:
+ return -1
+ if ta > tb:
+ return 1
+ return 0
+
+
+# ----- Install-cell parsing ----- #
+
+
+@dataclasses.dataclass
+class PipInvocation:
+ tool: str # "pip" | "uv-pip"
+ flags: set[str] # {'--no-deps', '--upgrade', '--force-reinstall', ...}
+ packages: list[str] # raw package specifiers (e.g. 'transformers==5.5.0')
+ raw: str
+ line_no: int = 0
+
+
+PIP_LINE_RE = re.compile(
+ r"^\s*!\s*(?P(?:uv\s+)?pip)\s+(?:install|uninstall)\b(?P.*)$",
+ re.IGNORECASE,
+)
+NON_PKG_FLAG_TAKES_VAL = {
+ "-r",
+ "--requirement",
+ "-c",
+ "--constraint",
+ "-i",
+ "--index-url",
+ "--extra-index-url",
+ "--find-links",
+ "-e",
+ "--editable",
+ "--target",
+ "--prefix",
+}
+
+
+def parse_pip_line(line: str, line_no: int = 0) -> PipInvocation | None:
+ m = PIP_LINE_RE.match(line)
+ if not m:
+ return None
+ tool = "uv-pip" if "uv" in m.group("tool") else "pip"
+ rest = m.group("rest")
+ # Strip trailing comment.
+ rest = re.split(r"(? list[tuple[int, str]]:
+ """Return (logical_line_no, joined_text) for each logical line, treating
+ a trailing backslash as a continuation. Logical line numbers point at the
+ first physical line of each logical line."""
+ out: list[tuple[int, str]] = []
+ buf = ""
+ start = 0
+ for i, raw in enumerate(text.splitlines(), start = 1):
+ if buf == "":
+ start = i
+ if raw.rstrip().endswith("\\"):
+ buf += raw.rstrip()[:-1] + " "
+ else:
+ buf += raw
+ out.append((start, buf))
+ buf = ""
+ if buf:
+ out.append((start, buf))
+ return out
+
+
+def iter_pip_invocations(install_cell: str) -> Iterator[PipInvocation]:
+ for line_no, line in _glue_line_continuations(install_cell):
+ inv = parse_pip_line(line, line_no)
+ if inv is not None:
+ yield inv
+
+
+# Spec parsing: only what we need (no full PEP 440).
+SPEC_RE = re.compile(r"^(?P[A-Za-z0-9._-]+)(?:\[[^\]]*\])?(?P.*)$")
+OP_VERSION_RE = re.compile(r"(==|>=|<=|!=|~=|>|<)\s*([0-9][^,;\s]*)")
+
+
+@dataclasses.dataclass
+class SpecParts:
+ name: str
+ pins: list[tuple[str, str]] # list of (op, version)
+ raw: str
+
+
+def parse_spec(spec: str) -> SpecParts | None:
+ spec = spec.strip().strip('"').strip("'")
+ if not spec or spec.startswith("-") or "://" in spec:
+ return None
+ m = SPEC_RE.match(spec)
+ if not m:
+ return None
+ name = m.group("name").lower()
+ rest = m.group("rest")
+ pins = OP_VERSION_RE.findall(rest)
+ return SpecParts(name = name, pins = pins, raw = spec)
+
+
+def explicit_pin(spec: SpecParts) -> str | None:
+ for op, ver in spec.pins:
+ if op == "==":
+ return ver
+ return None
+
+
+# ----- PyPI metadata cache ----- #
+
+
+def pypi_metadata(name: str, version: str) -> dict[str, Any] | None:
+ PYPI_CACHE_DIR.mkdir(parents = True, exist_ok = True)
+ safe = re.sub(r"[^A-Za-z0-9._-]", "_", f"{name.lower()}__{version}")
+ path = PYPI_CACHE_DIR / f"{safe}.json"
+ if path.is_file():
+ try:
+ return json.loads(path.read_text())
+ except json.JSONDecodeError:
+ pass
+ url = f"https://pypi.org/pypi/{name}/{version}/json"
+ try:
+ with urllib.request.urlopen(url, timeout = 10) as r:
+ data = json.loads(r.read())
+ except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError):
+ return None
+ path.write_text(json.dumps(data))
+ return data
+
+
+def transitive_constraint(
+ name: str, version: str, target: str
+) -> tuple[str | None, list[str]]:
+ """Return (raw_specifier_string_or_None, list_of_(op,version) tuples)
+ for the constraint that `name==version` places on `target`.
+ """
+ md = pypi_metadata(name, version)
+ if not md:
+ return None, []
+ info = md.get("info", {}) or {}
+ requires = info.get("requires_dist") or []
+ target_l = target.lower()
+ for req in requires:
+ # Examples: 'tokenizers (<=0.23.0,>=0.22.0)', 'tokenizers <=0.23.0,>=0.22.0',
+ # 'tokenizers (>=0.22.0,<=0.23.0); python_version >= "3.9"'
+ head = req.split(";", 1)[0].strip()
+ m = re.match(r"^([A-Za-z0-9._-]+)\s*\(?([^)]*)?\)?\s*$", head)
+ if not m:
+ continue
+ if m.group(1).lower() != target_l:
+ continue
+ spec = (m.group(2) or "").strip()
+ return spec, OP_VERSION_RE.findall(spec)
+ return None, []
+
+
+def constraint_satisfied(version: str, ops: list[tuple[str, str]]) -> bool:
+ if not ops:
+ return True
+ for op, v in ops:
+ c = cmp_versions(version, v)
+ if op == "==":
+ if c != 0:
+ return False
+ elif op == ">=":
+ if c < 0:
+ return False
+ elif op == "<=":
+ if c > 0:
+ return False
+ elif op == ">":
+ if c <= 0:
+ return False
+ elif op == "<":
+ if c >= 0:
+ return False
+ elif op == "!=":
+ if c == 0:
+ return False
+ return True
+
+
+# ----- Resolved set ----- #
+
+
+def resolved_set(install_cell: str, colab: dict[str, str]) -> dict[str, str]:
+ """Merge install-cell explicit constraints with Colab pip-freeze. Cell
+ wins.
+
+ Resolution order per package, when more than one form is present:
+ 1. Exact `==V` pin in any install line (definitive).
+ 2. Upper-bound `<=V` constraint (pip picks the highest
+ allowed; that's V).
+ 3. Colab pip-freeze fallback.
+
+ The lower-bound `>=V` is intentionally NOT reflected here — a `>=V`
+ by itself doesn't change the resolved version when a higher
+ Colab-preinstalled version is already in scope. (R-INST-003 calls
+ `_install_cell_lower_bound` separately to model that case.)
+ """
+ out = dict(colab)
+ pinned: set[str] = set()
+ upper_bounds: dict[str, str] = {}
+ for inv in iter_pip_invocations(install_cell):
+ for raw in inv.packages:
+ sp = parse_spec(raw)
+ if sp is None:
+ continue
+ for op, ver in sp.pins:
+ if op == "==":
+ out[sp.name] = ver
+ pinned.add(sp.name)
+ elif op == "<=" and sp.name not in pinned:
+ if (
+ sp.name not in upper_bounds
+ or cmp_versions(ver, upper_bounds[sp.name]) < 0
+ ):
+ upper_bounds[sp.name] = ver
+ # Apply upper bounds where Colab's preinstall violates them.
+ for name, ub in upper_bounds.items():
+ if name in pinned:
+ continue
+ existing = out.get(name)
+ if existing is None or cmp_versions(existing, ub) > 0:
+ out[name] = ub
+ return out
+
+
+# ----- Rules ----- #
+
+
+def rule_inst_001_git_plus(
+ install_cell: str, file: str, cell_idx: int
+) -> list[Finding]:
+ findings: list[Finding] = []
+ for inv in iter_pip_invocations(install_cell):
+ if any("git+" in p for p in inv.packages) or "git+" in inv.raw:
+ if any(allowed in inv.raw for allowed in GIT_PLUS_ALLOWLIST):
+ continue
+ findings.append(
+ Finding(
+ rule = "R-INST-001",
+ file = file,
+ cell = cell_idx,
+ line = inv.line_no,
+ severity = "error",
+ message = "install line uses `git+` (volatile, not pinned to a release)",
+ hint = f"replace with a `pip install foo==X.Y.Z` from PyPI; allow-list is {GIT_PLUS_ALLOWLIST}",
+ )
+ )
+ return findings
+
+
+def rule_inst_002_no_deps_transitive(
+ install_cell: str, colab: dict[str, str], file: str, cell_idx: int
+) -> list[Finding]:
+ findings: list[Finding] = []
+ res = resolved_set(install_cell, colab)
+ for inv in iter_pip_invocations(install_cell):
+ if "--no-deps" not in inv.flags:
+ continue
+ for raw in inv.packages:
+ sp = parse_spec(raw)
+ if sp is None:
+ continue
+ v = explicit_pin(sp)
+ if v is None:
+ continue
+ # Check transitive constraints on a curated short list of pkgs we
+ # care about (transformers/peft/trl/accelerate/torchao/torchcodec).
+ for target in (
+ "tokenizers",
+ "torchao",
+ "accelerate",
+ "datasets",
+ "huggingface-hub",
+ "huggingface_hub",
+ ):
+ spec_str, ops = transitive_constraint(sp.name, v, target)
+ if not ops:
+ continue
+ resolved_target = res.get(target.replace("_", "-"), res.get(target))
+ if resolved_target is None:
+ continue
+ if not constraint_satisfied(resolved_target, ops):
+ findings.append(
+ Finding(
+ rule = "R-INST-002",
+ file = file,
+ cell = cell_idx,
+ line = inv.line_no,
+ severity = "error",
+ message = f"`--no-deps {sp.name}=={v}` leaves transitive `{target}` unpinned: resolved {resolved_target} violates {sp.name}'s requirement {spec_str!r}",
+ hint = f'add `"{target}>={ops[0][1]},<={ops[-1][1]}"` (or the exact window from the metadata) to the same install line',
+ )
+ )
+ return findings
+
+
+def _install_cell_lower_bound(install_cell: str, target: str) -> str | None:
+ """Return the highest LOWER bound that any install line places on `target`,
+ or None if no constraint is present. Treats `==V` as both lower and upper.
+ Used by R-INST-003: a `pip install torchao>=0.16.0` line is enough to
+ satisfy a `torchao>=0.16.0` floor even though it's not a `==` pin."""
+ best: str | None = None
+ for inv in iter_pip_invocations(install_cell):
+ for raw in inv.packages:
+ sp = parse_spec(raw)
+ if sp is None or sp.name != target:
+ continue
+ for op, ver in sp.pins:
+ if op in ("==", ">="):
+ if best is None or cmp_versions(ver, best) > 0:
+ best = ver
+ return best
+
+
+def rule_inst_003_peft_torchao(
+ install_cell: str, colab: dict[str, str], file: str, cell_idx: int
+) -> list[Finding]:
+ findings: list[Finding] = []
+ res = resolved_set(install_cell, colab)
+ peft_v = res.get("peft")
+ if not peft_v:
+ return findings
+ torchao_explicit = _install_cell_lower_bound(install_cell, "torchao")
+ torchao_resolved = torchao_explicit or res.get("torchao")
+ for floor in PEFT_TORCHAO_FLOOR:
+ if cmp_versions(peft_v, floor["trigger_peft"]) >= 0:
+ if (
+ torchao_resolved is None
+ or cmp_versions(torchao_resolved, floor["torchao_floor"]) < 0
+ ):
+ findings.append(
+ Finding(
+ rule = "R-INST-003",
+ file = file,
+ cell = cell_idx,
+ severity = "error",
+ message = f"resolved peft=={peft_v} requires torchao>={floor['torchao_floor']}; install cell asserts torchao={torchao_resolved or '(none)'}",
+ hint = f'add `!pip install --no-deps --upgrade "torchao>={floor["torchao_floor"]}"` to the install cell',
+ )
+ )
+ return findings
+
+
+def rule_inst_004_torchcodec_torch(
+ install_cell: str, colab: dict[str, str], file: str, cell_idx: int
+) -> list[Finding]:
+ findings: list[Finding] = []
+ res = resolved_set(install_cell, colab)
+ torch_v = res.get("torch")
+ codec_v = res.get("torchcodec")
+ if not torch_v or not codec_v:
+ return findings
+ t_minor = version_minor(torch_v)
+ c_minor = version_minor(codec_v)
+ allowed = TORCH_TORCHCODEC.get(t_minor)
+ if allowed is None:
+ return findings # unknown torch minor — don't flag
+ if c_minor not in allowed:
+ findings.append(
+ Finding(
+ rule = "R-INST-004",
+ file = file,
+ cell = cell_idx,
+ severity = "error",
+ message = f"torch=={torch_v} (minor {t_minor}) is incompatible with torchcodec=={codec_v} (minor {c_minor}); compatible minors: {sorted(allowed)}",
+ hint = f"pin `torchcodec=={sorted(allowed)[-1]}` (or remove the explicit pin and let pip resolve)",
+ )
+ )
+ return findings
+
+
+def rule_inst_005_transformers_tokenizers(
+ install_cell: str, colab: dict[str, str], file: str, cell_idx: int
+) -> list[Finding]:
+ """Fires only when transformers is installed with `--no-deps`. Without
+ `--no-deps`, pip resolves the correct tokenizers transitively, so the
+ rule would be a false positive (this is the case for older notebooks
+ that pin `transformers==4.51.3` but rely on pip's transitive resolver).
+ The rule targets the exact pattern PR #261b / #264 fixed:
+ `pip install --no-deps transformers==X` next to a Colab preinstall
+ `tokenizers` outside transformers's window."""
+ findings: list[Finding] = []
+ res = resolved_set(install_cell, colab)
+ tf = res.get("transformers")
+ tok = res.get("tokenizers")
+ if not tf or tok is None:
+ return findings
+ # Find the install line that pins transformers and check for --no-deps.
+ transformers_line_no_deps = False
+ for inv in iter_pip_invocations(install_cell):
+ for raw in inv.packages:
+ sp = parse_spec(raw)
+ if sp is None or sp.name != "transformers":
+ continue
+ if explicit_pin(sp) is None:
+ continue
+ if "--no-deps" in inv.flags:
+ transformers_line_no_deps = True
+ break
+ if transformers_line_no_deps:
+ break
+ if not transformers_line_no_deps:
+ return findings
+ spec_str, ops = transitive_constraint("transformers", tf, "tokenizers")
+ if not ops:
+ return findings
+ if not constraint_satisfied(tok, ops):
+ findings.append(
+ Finding(
+ rule = "R-INST-005",
+ file = file,
+ cell = cell_idx,
+ severity = "error",
+ message = f"`--no-deps transformers=={tf}` skips pip's transitive resolver; resolved tokenizers={tok} violates {spec_str}",
+ hint = f'pin `"tokenizers{spec_str}"` (or the matching window) on the same `--no-deps` line',
+ )
+ )
+ return findings
+
+
+_RE_DOUBLE_BANG = re.compile(r"^[ \t]*!{2,}\s*pip\b", re.MULTILINE)
+
+
+def rule_inst_006_double_bang(
+ install_cell: str, file: str, cell_idx: int
+) -> list[Finding]:
+ findings: list[Finding] = []
+ for m in _RE_DOUBLE_BANG.finditer(install_cell):
+ line_no = install_cell.count("\n", 0, m.start()) + 1
+ findings.append(
+ Finding(
+ rule = "R-INST-006",
+ file = file,
+ cell = cell_idx,
+ line = line_no,
+ severity = "warning",
+ message = "double-bang `!!pip` runs in a subshell; almost always a typo for `!pip`",
+ hint = "use a single `!`",
+ )
+ )
+ return findings
+
+
+# ----- AST-level rules over user-facing cells ----- #
+
+
+class _APIScanner(ast.NodeVisitor):
+ """Scan user-facing code cells for known deprecated patterns. R-API-001
+ (`for_training`/`for_inference`) is intentionally absent: those helpers
+ are still part of the live unsloth surface as of 2026-05; PR #221 removed
+ the calls cosmetically from Vision notebooks but did not deprecate the
+ methods. R-API-004 (live API surface diff) catches actual removals
+ dynamically without us hand-coding them."""
+
+ def __init__(self, file: str, cell_idx: int):
+ self.file = file
+ self.cell_idx = cell_idx
+ self.findings: list[Finding] = []
+
+ def visit_Call(self, node: ast.Call) -> None:
+ # SFTConfig with suboptimal optim (R-API-003).
+ # NOTE: PR #221 also stripped `gradient_checkpointing` /
+ # `gradient_checkpointing_kwargs` from a handful of vision notebooks,
+ # but those kwargs are still accepted by live TRL (verified against
+ # trl==0.25.1 in the unsloth workspace) so removing them was
+ # cosmetic, not a deprecation. We do NOT flag them. R-API-004 (live
+ # API surface diff in the api subcommand) is the right way to catch
+ # actual TRL signature drift.
+ if isinstance(node.func, ast.Name) and node.func.id == "SFTConfig":
+ for kw in node.keywords:
+ if (
+ kw.arg == "optim"
+ and isinstance(kw.value, ast.Constant)
+ and kw.value.value == "adamw_torch_fused"
+ ):
+ self.findings.append(
+ Finding(
+ rule = "R-API-003",
+ file = self.file,
+ cell = self.cell_idx,
+ line = kw.value.lineno,
+ severity = "warning",
+ message = "`optim='adamw_torch_fused'` is suboptimal under Unsloth's memory-efficient training",
+ hint = 'use `optim="adamw_8bit"` (or `"paged_adamw_8bit"` for GRPO)',
+ )
+ )
+ self.generic_visit(node)
+
+
+def scan_user_cells(nb: dict[str, Any], file: str) -> list[Finding]:
+ findings: list[Finding] = []
+ install_idxs = {i for i, _ in install_cells(nb)}
+ for i, src in code_cells(nb):
+ if i in install_idxs:
+ continue
+ try:
+ tree = ast.parse(src)
+ except SyntaxError:
+ continue
+ scanner = _APIScanner(file = file, cell_idx = i)
+ scanner.visit(tree)
+ findings.extend(scanner.findings)
+ return findings
+
+
+# ----- DONT_UPDATE_EXCEPTIONS coverage ----- #
+
+POLICY_CLAUSES_DEFAULT = [
+ # (id, regex, applies_to_predicate_on_install_cell_text)
+ (
+ "torchao-floor",
+ re.compile(r"torchao>=0\.16\.0"),
+ lambda cell: bool(re.search(r"\bpeft\b", cell)),
+ ),
+ (
+ "tokenizers-window",
+ re.compile(r"tokenizers>=0\.22\.0,<=0\.23\.0"),
+ lambda cell: bool(re.search(r"--no-deps[^\n]*transformers==", cell)),
+ ),
+]
+
+
+def extract_policy_clauses(
+ update_script: pathlib.Path,
+) -> list[tuple[str, re.Pattern[str], Any]]:
+ """Best-effort: scan update_all_notebooks.py for canonical phrases used by
+ multiple templates. Falls back to POLICY_CLAUSES_DEFAULT.
+
+ Today we use POLICY_CLAUSES_DEFAULT directly; the regex form is
+ intentionally permissive so a template-side reword (e.g. comment changes)
+ doesn't cause false positives. New clauses become 1-line PRs to this list.
+ """
+ return list(POLICY_CLAUSES_DEFAULT)
+
+
+def rule_l12_exceptions_coverage(notebooks_dir: pathlib.Path) -> list[Finding]:
+ findings: list[Finding] = []
+ update_script = notebooks_dir / "update_all_notebooks.py"
+ exceptions = _extract_dont_update_exceptions(update_script)
+ clauses = extract_policy_clauses(update_script)
+ for name in exceptions:
+ path = notebooks_dir / "nb" / name
+ if not path.is_file():
+ continue
+ nb = load_notebook(path)
+ for idx, cell in install_cells(nb):
+ for cid, pat, applies in clauses:
+ if not applies(cell):
+ continue
+ if not pat.search(cell):
+ findings.append(
+ Finding(
+ rule = "R-EXC-001",
+ file = str(path),
+ cell = idx,
+ severity = "error",
+ message = f"DONT_UPDATE_EXCEPTIONS notebook missing policy clause `{cid}` (pattern {pat.pattern!r})",
+ hint = f"add the matching install line; the regenerator can't reach this notebook",
+ )
+ )
+ return findings
+
+
+def _extract_dont_update_exceptions(update_script: pathlib.Path) -> list[str]:
+ if not update_script.is_file():
+ return []
+ src = update_script.read_text(encoding = "utf-8")
+ m = re.search(r"DONT_UPDATE_EXCEPTIONS\s*=\s*\[(.*?)\]", src, re.DOTALL)
+ if not m:
+ return []
+ out: list[str] = []
+ for line in m.group(1).splitlines():
+ m2 = re.match(r'\s*"([^"]+\.ipynb)"', line)
+ if m2:
+ out.append(m2.group(1))
+ return out
+
+
+# ----- Drift ----- #
+
+
+def cmd_drift(args: argparse.Namespace) -> int:
+ nbdir = pathlib.Path(args.notebooks_dir).resolve()
+ update_script = nbdir / "update_all_notebooks.py"
+ if not update_script.is_file():
+ print(f"FAIL: {update_script} not found", file = sys.stderr)
+ return 2
+ # Stash any pre-existing dirty state, run the updater, diff, restore.
+ head = (
+ subprocess.check_output(["git", "rev-parse", "HEAD"], cwd = nbdir)
+ .decode()
+ .strip()
+ )
+ subprocess.run(
+ ["git", "-C", str(nbdir), "stash", "--include-untracked"],
+ check = False,
+ capture_output = True,
+ )
+ try:
+ proc = subprocess.run(
+ [sys.executable, str(update_script)],
+ cwd = nbdir,
+ capture_output = True,
+ text = True,
+ timeout = 600,
+ )
+ except subprocess.TimeoutExpired:
+ print("FAIL: update_all_notebooks.py timed out (>600s)", file = sys.stderr)
+ return 2
+ if proc.returncode != 0:
+ print(
+ f"FAIL: update_all_notebooks.py exited {proc.returncode}", file = sys.stderr
+ )
+ sys.stderr.write(proc.stderr[-2000:])
+ return 2
+ diff_proc = subprocess.run(
+ ["git", "-C", str(nbdir), "diff", "--stat"], capture_output = True, text = True
+ )
+ findings: list[Finding] = []
+ if diff_proc.stdout.strip():
+ for line in diff_proc.stdout.splitlines():
+ findings.append(
+ Finding(
+ rule = "R-DRIFT-001",
+ file = line.strip(),
+ severity = "error",
+ message = "generator-vs-checked-in drift",
+ hint = "run `python update_all_notebooks.py` and commit the diff",
+ )
+ )
+ # Restore.
+ subprocess.run(
+ ["git", "-C", str(nbdir), "checkout", "."], check = False, capture_output = True
+ )
+ subprocess.run(
+ ["git", "-C", str(nbdir), "stash", "pop"], check = False, capture_output = True
+ )
+ _emit(findings)
+ return 0 if not findings else 1
+
+
+# ----- Convert ----- #
+
+
+def cmd_convert(args: argparse.Namespace) -> int:
+ nbdir = pathlib.Path(args.notebooks_dir).resolve()
+ out = pathlib.Path(args.out).resolve()
+ out.mkdir(parents = True, exist_ok = True)
+ converter = HERE / "notebook_to_python.py"
+ if not converter.is_file():
+ print(f"FAIL: {converter} not found", file = sys.stderr)
+ return 2
+ # Convert in batches; the script accepts multiple notebooks at once.
+ notebooks = list(iter_notebooks(nbdir, include_templates = True))
+ failed: list[Finding] = []
+ BATCH = 32
+ for i in range(0, len(notebooks), BATCH):
+ chunk = notebooks[i : i + BATCH]
+ proc = subprocess.run(
+ [sys.executable, str(converter), "-o", str(out), *map(str, chunk)],
+ capture_output = True,
+ text = True,
+ )
+ if proc.returncode != 0:
+ for nb in chunk:
+ failed.append(
+ Finding(
+ rule = "R-CONV-001",
+ file = str(nb),
+ severity = "error",
+ message = "notebook_to_python.py failed for this notebook",
+ hint = proc.stderr[-200:].strip(),
+ )
+ )
+ print(
+ f"converted {len(notebooks) - len(failed)}/{len(notebooks)} notebooks to {out}"
+ )
+ _emit(failed)
+ return 0 if not failed else 1
+
+
+# ----- Lint (combined) ----- #
+
+
+def cmd_lint(args: argparse.Namespace) -> int:
+ nbdir = pathlib.Path(args.notebooks_dir).resolve()
+ colab_path = (
+ pathlib.Path(args.colab_pin).resolve()
+ if args.colab_pin
+ else COLAB_FALLBACK_FILE
+ )
+ colab = parse_pip_freeze(colab_path)
+ if not colab:
+ print(
+ f"WARN: Colab pip-freeze empty / missing at {colab_path}; using empty oracle",
+ file = sys.stderr,
+ )
+
+ findings: list[Finding] = []
+ notebooks = list(iter_notebooks(nbdir))
+ for path in notebooks:
+ try:
+ nb = load_notebook(path)
+ except (json.JSONDecodeError, OSError) as e:
+ findings.append(
+ Finding(
+ rule = "R-CONV-002",
+ file = str(path),
+ severity = "error",
+ message = f"notebook unreadable: {e}",
+ )
+ )
+ continue
+ rel = str(path.relative_to(nbdir))
+ env = target_environment(rel)
+ # The Colab oracle is the source of truth ONLY for Colab notebooks.
+ # Other targets (amd / kaggle / dgx_spark) have their own runtime
+ # preinstall sets that aren't tracked here yet, so we apply the
+ # environment-agnostic rules and skip the Colab-specific ones.
+ oracle = colab if env == "colab" else {}
+ cells = install_cells(nb)
+ # Per-cell rules: forbid-pattern checks scoped to a single line.
+ for idx, cell in cells:
+ findings += rule_inst_001_git_plus(cell, rel, idx)
+ findings += rule_inst_006_double_bang(cell, rel, idx)
+ # Whole-notebook rules: a notebook's install steps are sometimes split
+ # across multiple cells (initial install + post-install bumps). Merge
+ # all install cells before resolving compat against Colab.
+ merged = "\n".join(c for _, c in cells)
+ if env == "colab" and merged:
+ first_cell = cells[0][0] if cells else None
+ findings += rule_inst_003_peft_torchao(merged, oracle, rel, first_cell)
+ findings += rule_inst_004_torchcodec_torch(merged, oracle, rel, first_cell)
+ findings += rule_inst_005_transformers_tokenizers(
+ merged, oracle, rel, first_cell
+ )
+ if not args.no_pypi:
+ findings += rule_inst_002_no_deps_transitive(
+ merged, oracle, rel, first_cell
+ )
+ findings += scan_user_cells(nb, rel)
+ _emit(findings)
+ return 0 if not any(f.severity == "error" for f in findings) else 1
+
+
+# ----- Exceptions coverage ----- #
+
+
+def cmd_exceptions(args: argparse.Namespace) -> int:
+ findings = rule_l12_exceptions_coverage(pathlib.Path(args.notebooks_dir).resolve())
+ _emit(findings)
+ return 0 if not findings else 1
+
+
+# ----- API surface scan ----- #
+
+
+def cmd_api(args: argparse.Namespace) -> int:
+ surface_path = pathlib.Path(args.surface).resolve()
+ if not surface_path.is_file():
+ print(
+ f"FAIL: {surface_path} not found; run dump-api-surface first",
+ file = sys.stderr,
+ )
+ return 2
+ surface = json.loads(surface_path.read_text())
+ converted = pathlib.Path(args.converted_dir).resolve()
+ findings: list[Finding] = []
+ fast_models = (
+ set(surface.get("FastVisionModel", []))
+ | set(surface.get("FastLanguageModel", []))
+ | set(surface.get("FastModel", []))
+ )
+ for py in sorted(converted.glob("*.py")):
+ try:
+ tree = ast.parse(py.read_text(encoding = "utf-8"))
+ except SyntaxError:
+ continue
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
+ base = node.func.value
+ if isinstance(base, ast.Name) and base.id in (
+ "FastVisionModel",
+ "FastLanguageModel",
+ "FastModel",
+ ):
+ surface_set = set(surface.get(base.id, []))
+ if surface_set and node.func.attr not in surface_set:
+ findings.append(
+ Finding(
+ rule = "R-API-004",
+ file = str(py.name),
+ line = node.lineno,
+ severity = "error",
+ message = f"`{base.id}.{node.func.attr}` is not in the live API surface for the pinned unsloth tag",
+ hint = "check the unsloth changelog for a renamed/removed API",
+ )
+ )
+ _emit(findings)
+ return 0 if not findings else 1
+
+
+# ----- Orchestrator ----- #
+
+
+def cmd_all(args: argparse.Namespace) -> int:
+ rcs: list[int] = []
+ rcs.append(cmd_drift(argparse.Namespace(notebooks_dir = args.notebooks_dir)))
+ rcs.append(
+ cmd_lint(
+ argparse.Namespace(
+ notebooks_dir = args.notebooks_dir,
+ colab_pin = args.colab_pin,
+ no_pypi = args.no_pypi,
+ )
+ )
+ )
+ rcs.append(cmd_exceptions(argparse.Namespace(notebooks_dir = args.notebooks_dir)))
+ return 0 if all(rc == 0 for rc in rcs) else 1
+
+
+def cmd_refresh_colab(args: argparse.Namespace) -> int:
+ """Pull the latest Colab pip-freeze.gpu.txt and write to disk."""
+ out = pathlib.Path(args.out).resolve()
+ out.parent.mkdir(parents = True, exist_ok = True)
+ try:
+ with urllib.request.urlopen(COLAB_PIP_FREEZE_URL, timeout = 15) as r:
+ data = r.read()
+ except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as e:
+ print(f"FAIL: could not fetch {COLAB_PIP_FREEZE_URL}: {e}", file = sys.stderr)
+ return 2
+ out.write_bytes(data)
+ print(f"wrote {len(data)} bytes to {out}")
+ return 0
+
+
+# ----- Helpers ----- #
+
+
+def _emit(findings: list[Finding]) -> None:
+ n_err = sum(1 for f in findings if f.severity == "error")
+ n_warn = sum(1 for f in findings if f.severity == "warning")
+ for f in findings:
+ print(json.dumps(f.to_dict(), separators = (",", ":")))
+ print(f"# total: {n_err} errors, {n_warn} warnings", file = sys.stderr)
+
+
+def main(argv: list[str] | None = None) -> int:
+ p = argparse.ArgumentParser(prog = "notebook_validator")
+ sub = p.add_subparsers(dest = "cmd", required = True)
+
+ pa = sub.add_parser("drift")
+ pa.add_argument("--notebooks-dir", required = True)
+
+ pa = sub.add_parser("convert")
+ pa.add_argument("--notebooks-dir", required = True)
+ pa.add_argument("--out", required = True)
+
+ pa = sub.add_parser("lint")
+ pa.add_argument("--notebooks-dir", required = True)
+ pa.add_argument("--colab-pin", default = None)
+ pa.add_argument(
+ "--no-pypi",
+ action = "store_true",
+ help = "skip rules that require live PyPI metadata fetches",
+ )
+
+ pa = sub.add_parser("exceptions")
+ pa.add_argument("--notebooks-dir", required = True)
+
+ pa = sub.add_parser("api")
+ pa.add_argument("--converted-dir", required = True)
+ pa.add_argument("--surface", required = True)
+
+ pa = sub.add_parser("all")
+ pa.add_argument("--notebooks-dir", required = True)
+ pa.add_argument("--colab-pin", default = None)
+ pa.add_argument("--no-pypi", action = "store_true")
+
+ pa = sub.add_parser("refresh-colab")
+ pa.add_argument("--out", default = str(COLAB_FALLBACK_FILE))
+
+ args = p.parse_args(argv)
+ return {
+ "drift": cmd_drift,
+ "convert": cmd_convert,
+ "lint": cmd_lint,
+ "exceptions": cmd_exceptions,
+ "api": cmd_api,
+ "all": cmd_all,
+ "refresh-colab": cmd_refresh_colab,
+ }[args.cmd](args)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/notebooks/__init__.py b/tests/notebooks/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/notebooks/test_validator_fixtures.py b/tests/notebooks/test_validator_fixtures.py
new file mode 100644
index 0000000000..836bb96715
--- /dev/null
+++ b/tests/notebooks/test_validator_fixtures.py
@@ -0,0 +1,294 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team.
+"""
+Golden-fixture tests for scripts/notebook_validator.py.
+
+Each test reconstructs the broken-state install cell that one of the
+referenced unslothai/notebooks PRs fixed, and asserts the matching rule
+fires. The fixed-state tests prove the rule falls silent after the fix.
+
+Cross-references:
+ PR #258 -> R-INST-003 (peft/torchao floor)
+ PR #260 -> R-EXC-001 (DONT_UPDATE_EXCEPTIONS coverage; covered by
+ an integration test pointing at a real
+ notebooks checkout)
+ PR #261a -> R-INST-004 (torch/torchcodec ABI)
+ PR #261b -> R-INST-005 (transformers --no-deps + tokenizers window)
+ PR #264 -> R-INST-005 (same class as #261b)
+ PR #221 -> R-INST-001 (forbid git+ HEAD installs)
+ 51b1462 -> R-DRIFT-001 (drift; integration-tested separately)
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import pytest
+
+HERE = Path(__file__).resolve().parent
+SCRIPTS_DIR = HERE.parent.parent / "scripts"
+sys.path.insert(0, str(SCRIPTS_DIR))
+
+import notebook_validator as nv # noqa: E402
+
+# Snapshot of Colab GPU pip-freeze that recreates the bug environments
+# below. Real CI uses scripts/data/colab_pip_freeze.gpu.txt; tests use a
+# small inline subset so the unit cases are hermetic.
+COLAB_2026_05 = {
+ "torch": "2.10.0+cu128",
+ "torchao": "0.10.0",
+ "torchcodec": "0.10.0+cu128",
+ "transformers": "5.0.0",
+ "tokenizers": "0.22.2",
+ "peft": "0.19.1",
+ "accelerate": "1.13.0",
+ "datasets": "4.0.0",
+}
+
+
+# ---------- R-INST-001 : forbid git+ HEAD ------------------------------- #
+
+
+def test_r_inst_001_fires_on_transformers_git_head():
+ cell = """%%capture
+!pip install --force-reinstall git+https://github.com/huggingface/transformers.git
+"""
+ findings = nv.rule_inst_001_git_plus(cell, "fixture", 0)
+ assert any(f.rule == "R-INST-001" for f in findings)
+
+
+def test_r_inst_001_silent_after_pin():
+ cell = """%%capture
+!pip install transformers==5.5.0
+"""
+ findings = nv.rule_inst_001_git_plus(cell, "fixture", 0)
+ assert findings == []
+
+
+def test_r_inst_001_allowlist_unsloth_zoo_git():
+ cell = """%%capture
+!pip install --no-build-isolation git+https://github.com/state-spaces/mamba.git@main
+!pip install "unsloth_zoo[base] @ git+https://github.com/unslothai/unsloth-zoo"
+"""
+ findings = nv.rule_inst_001_git_plus(cell, "fixture", 0)
+ assert findings == []
+
+
+# ---------- R-INST-003 : peft / torchao floor (PR #258) ------------------ #
+
+
+def test_r_inst_003_fires_when_peft_19_with_no_torchao_bump():
+ cell = """%%capture
+!pip install --no-deps peft trl unsloth_zoo
+"""
+ findings = nv.rule_inst_003_peft_torchao(cell, COLAB_2026_05, "fixture", 0)
+ assert any(f.rule == "R-INST-003" for f in findings)
+
+
+def test_r_inst_003_silent_when_torchao_bumped():
+ cell = """%%capture
+!pip install --no-deps peft trl unsloth_zoo
+!pip install --no-deps --upgrade "torchao>=0.16.0"
+"""
+ findings = nv.rule_inst_003_peft_torchao(cell, COLAB_2026_05, "fixture", 0)
+ assert findings == []
+
+
+def test_r_inst_003_silent_when_torchao_pinned_high():
+ cell = """%%capture
+!pip install --no-deps peft trl
+!pip install torchao==0.17.0
+"""
+ findings = nv.rule_inst_003_peft_torchao(cell, COLAB_2026_05, "fixture", 0)
+ assert findings == []
+
+
+# ---------- R-INST-004 : torch / torchcodec ABI (PR #261a) --------------- #
+
+
+def test_r_inst_004_fires_torch_2_7_with_torchcodec_0_6():
+ cell = """%%capture
+!uv pip install "torch==2.7.1"
+!uv pip install --no-deps "torchcodec==0.6.0"
+"""
+ findings = nv.rule_inst_004_torchcodec_torch(cell, COLAB_2026_05, "fixture", 0)
+ assert any(f.rule == "R-INST-004" for f in findings)
+
+
+def test_r_inst_004_silent_when_torch_2_7_with_torchcodec_0_5():
+ cell = """%%capture
+!uv pip install "torch==2.7.1"
+!uv pip install --no-deps "torchcodec==0.5"
+"""
+ findings = nv.rule_inst_004_torchcodec_torch(cell, COLAB_2026_05, "fixture", 0)
+ assert findings == []
+
+
+# ---------- R-INST-005 : transformers + tokenizers window (PRs #261b/#264) -- #
+
+
+def test_r_inst_005_fires_no_deps_transformers_55_without_tokenizers_pin(monkeypatch):
+ """PR #264: --no-deps transformers==5.5.0 leaves Colab tokenizers in
+ place; if Colab ever ships tokenizers > 0.23.0 this breaks."""
+ cell = """%%capture
+!pip install --no-deps transformers==5.5.0
+"""
+ # Fake a Colab snapshot where tokenizers has just bumped past the window
+ # transformers 5.5.0 supports.
+ colab = dict(COLAB_2026_05, tokenizers = "0.23.5")
+
+ def fake_meta(name, version):
+ if name.lower() == "transformers" and version == "5.5.0":
+ return {"info": {"requires_dist": ["tokenizers (>=0.22.0,<=0.23.0)"]}}
+ return None
+
+ monkeypatch.setattr(nv, "pypi_metadata", fake_meta)
+
+ findings = nv.rule_inst_005_transformers_tokenizers(cell, colab, "fixture", 0)
+ assert any(f.rule == "R-INST-005" for f in findings)
+
+
+def test_r_inst_005_silent_when_no_deps_pins_tokenizers(monkeypatch):
+ cell = """%%capture
+!pip install --no-deps transformers==5.5.0 "tokenizers>=0.22.0,<=0.23.0"
+"""
+
+ def fake_meta(name, version):
+ if name.lower() == "transformers" and version == "5.5.0":
+ return {"info": {"requires_dist": ["tokenizers (>=0.22.0,<=0.23.0)"]}}
+ return None
+
+ monkeypatch.setattr(nv, "pypi_metadata", fake_meta)
+ # Cell wins over Colab; resolved tokenizers will be 0.23.0.
+ colab = dict(COLAB_2026_05, tokenizers = "0.23.5")
+
+ findings = nv.rule_inst_005_transformers_tokenizers(cell, colab, "fixture", 0)
+ assert findings == []
+
+
+def test_r_inst_005_silent_without_no_deps(monkeypatch):
+ """If --no-deps is absent, pip resolves tokenizers transitively; the
+ rule must NOT fire (this is the false-positive case from notebooks like
+ Whisper.ipynb that pin transformers but rely on pip's resolver)."""
+ cell = """%%capture
+!pip install transformers==4.51.3
+"""
+
+ def fake_meta(name, version):
+ if name.lower() == "transformers" and version == "4.51.3":
+ return {"info": {"requires_dist": ["tokenizers (>=0.21,<0.22)"]}}
+ return None
+
+ monkeypatch.setattr(nv, "pypi_metadata", fake_meta)
+ colab = COLAB_2026_05
+ findings = nv.rule_inst_005_transformers_tokenizers(cell, colab, "fixture", 0)
+ assert findings == []
+
+
+# ---------- R-API-003 : suboptimal optim warning (PR #221, partial) ------ #
+
+import json
+from pathlib import Path as _P
+
+
+def _nb_with_code(*sources: str) -> dict:
+ return {
+ "cells": [{"cell_type": "code", "source": s} for s in sources],
+ "metadata": {},
+ "nbformat": 4,
+ "nbformat_minor": 5,
+ }
+
+
+def test_r_api_003_fires_on_adamw_torch_fused():
+ nb = _nb_with_code(
+ "%%capture\n!pip install unsloth\n",
+ 'from trl import SFTConfig\ntrainer = SFTConfig(optim="adamw_torch_fused")\n',
+ )
+ findings = nv.scan_user_cells(nb, "fixture")
+ assert any(f.rule == "R-API-003" for f in findings)
+
+
+def test_r_api_003_silent_on_adamw_8bit():
+ nb = _nb_with_code(
+ "%%capture\n!pip install unsloth\n",
+ 'from trl import SFTConfig\ntrainer = SFTConfig(optim="adamw_8bit")\n',
+ )
+ findings = nv.scan_user_cells(nb, "fixture")
+ assert findings == []
+
+
+# ---------- Environment classifier --------------------------------------- #
+
+
+@pytest.mark.parametrize(
+ "path,expected",
+ [
+ ("nb/Llama3.1_(8B)-Alpaca.ipynb", "colab"),
+ ("nb/Kaggle-Llama3.1_(8B)-Alpaca.ipynb", "kaggle"),
+ ("kaggle/Gemma4_(31B)-Text.ipynb", "kaggle"),
+ ("nb/AMD-Llama3.1_(8B)-Alpaca.ipynb", "amd"),
+ ("nb/HuggingFace Course-Qwen3_(4B)-GRPO.ipynb", "colab"),
+ (
+ "nb/gpt_oss_(20B)_Reinforcement_Learning_2048_Game_DGX_Spark.ipynb",
+ "dgx_spark",
+ ),
+ ],
+)
+def test_environment_classifier(path, expected):
+ assert nv.target_environment(path) == expected
+
+
+# ---------- Integration: walk the live notebooks repo (skipped if absent) -- #
+
+
+def _live_notebooks_dir() -> Path | None:
+ candidates = [
+ Path(__file__).resolve().parents[3] / "notebooks", # workspace sibling
+ Path("/mnt/disks/unslothai/ubuntu/workspace_12/notebooks"),
+ ]
+ for p in candidates:
+ if (p / "update_all_notebooks.py").is_file():
+ return p
+ return None
+
+
+@pytest.mark.skipif(
+ _live_notebooks_dir() is None,
+ reason = "unslothai/notebooks not cloned at sibling path",
+)
+def test_exceptions_passes_on_head():
+ """L1.2 must be silent on the live HEAD of unslothai/notebooks. If this
+ test fires, either DONT_UPDATE_EXCEPTIONS gained a notebook missing a
+ policy clause (real bug) or the policy clause set is stale."""
+ findings = nv.rule_l12_exceptions_coverage(_live_notebooks_dir())
+ assert findings == [], findings
+
+
+@pytest.mark.skipif(
+ _live_notebooks_dir() is None,
+ reason = "unslothai/notebooks not cloned at sibling path",
+)
+def test_lint_smoke_no_module_errors():
+ """The lint subcommand should walk every nb/kaggle without crashing.
+ (We accept findings -- those are the validator doing its job.)"""
+ import subprocess
+
+ rc = subprocess.run(
+ [
+ sys.executable,
+ str(SCRIPTS_DIR / "notebook_validator.py"),
+ "lint",
+ "--no-pypi",
+ "--notebooks-dir",
+ str(_live_notebooks_dir()),
+ "--colab-pin",
+ str(SCRIPTS_DIR / "data" / "colab_pip_freeze.gpu.txt"),
+ ],
+ capture_output = True,
+ text = True,
+ timeout = 120,
+ )
+ # rc=0 means clean, rc=1 means findings reported, rc=2 means crash.
+ assert rc.returncode in (0, 1), rc.stderr[-2000:]