From bfb5c2872c46a431bd274362af28eab9d118159f Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 7 May 2026 11:42:43 +0000 Subject: [PATCH] CI(notebooks): cross-repo validator for unslothai/notebooks New PR-time + scheduled workflow that walks every nb/, kaggle/, and original_template/ notebook in unslothai/notebooks and statically validates the install cells and user-facing code against: - googlecolab/backend-info pip-freeze.gpu.txt (Colab oracle, refreshed on every run; fallback snapshot committed under scripts/data/). - PyPI metadata for transitive constraint resolution. - Hardcoded torch/torchcodec ABI table. - Hardcoded peft/torchao floor table. - The live unsloth + trl API surface, introspected under tests/_zoo_aggressive_cuda_spoof.py so the api job runs on a GPU-less ubuntu-latest runner. Catches the bug classes from notebooks#258 / #260 / #261 / #264 / #221 and commit 51b1462 mechanically: R-INST-001 forbid git+ HEAD installs (notebooks#221) R-INST-002 --no-deps + transitive constraint violation R-INST-003 peft 0.19+ requires torchao 0.16.0+ (notebooks#258) R-INST-004 torch <-> torchcodec ABI mismatch (notebooks#261a) R-INST-005 --no-deps transformers + Colab tokenizers drift (notebooks#261b / #264) R-INST-006 forbid !!pip R-API-003 adamw_torch_fused -> adamw_8bit hint (warning) R-API-004 notebook references symbols outside live unsloth surface R-EXC-001 DONT_UPDATE_EXCEPTIONS notebooks must satisfy the same policy clauses as generated notebooks (notebooks#260) R-DRIFT-001 update_all_notebooks.py emits no diff (commit 51b1462) R-CONV-001 notebook_to_python.py converts every .ipynb cleanly Files: .github/workflows/notebooks-ci.yml PR-time + cron + dispatch scripts/notebook_validator.py 1148 LOC, single-file scripts/notebook_to_python.py battle-tested converter scripts/data/colab_pip_freeze.gpu.txt fallback snapshot scripts/data/colab_to_cpu_pin.json cu128 -> CPU wheel map tests/notebooks/test_validator_fixtures.py 21 golden tests, all green CPU-only by design. The api-introspect job follows the existing consolidated-tests-ci spoof pattern (lines 309/417/536/626/826/1081/ 1586/1998 of consolidated-tests-ci.yml). The smoke-install job is opt-in via workflow_dispatch and stubs torchcodec since no CPU wheel exists. Validated on the live unslothai/notebooks@7af0ac0f tree: every fixture test passes, exceptions check is silent, lint surfaces 27 errors + 6 warnings on real notebooks (mix of #258-class regressions in 6 nb/ notebooks the previous template fixes did not reach, plus 14 git+-HEAD installs in hand-tuned exception notebooks). --- .github/workflows/notebooks-ci.yml | 320 ++++++ .gitignore | 2 + scripts/data/colab_pip_freeze.gpu.txt | 731 +++++++++++++ scripts/data/colab_to_cpu_pin.json | 36 + scripts/notebook_to_python.py | 292 +++++ scripts/notebook_validator.py | 1148 ++++++++++++++++++++ tests/notebooks/__init__.py | 0 tests/notebooks/test_validator_fixtures.py | 294 +++++ 8 files changed, 2823 insertions(+) create mode 100644 .github/workflows/notebooks-ci.yml create mode 100644 scripts/data/colab_pip_freeze.gpu.txt create mode 100644 scripts/data/colab_to_cpu_pin.json create mode 100644 scripts/notebook_to_python.py create mode 100644 scripts/notebook_validator.py create mode 100644 tests/notebooks/__init__.py create mode 100644 tests/notebooks/test_validator_fixtures.py 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:]