#!/opt/unsloth-venv/bin/python # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0 """unsloth-run: execute an unslothai/notebooks notebook unchanged, headless. The robust driven path for the Docker image: it reads the notebook, figures out which transformers version it wants (its install-cell pin, else the model-name tier), launches the kernel with that sidecar on PYTHONPATH so the whole kernel process uses a coherent transformers, and executes every cell with nbconvert. The notebook's own install cell still runs through the pip/uv shim, so it is safe and idempotent (the baked torch/vLLM stack is never clobbered). Usage: unsloth-run [--out OUT.ipynb] [--timeout SECONDS] [--transformers X.Y.Z] # force a version, skip auto-detect A raw github URL (raw.githubusercontent.com/.../nb/Foo.ipynb) is fetched first. """ import argparse, json, os, re, shutil, subprocess, sys, tempfile, urllib.request sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) try: import unsloth_nb_compat as compat except Exception: compat = None _PIN_RE = re.compile(r"transformers\s*==\s*([0-9][0-9A-Za-z.\-]*)") _MODEL_RE = re.compile(r"""from_pretrained\(\s*['"]([^'"]+)['"]""") _MODEL_NAME_RE = re.compile(r"""model_name\s*=\s*['"]([^'"]+)['"]""") def _load(path_or_url): if path_or_url.startswith(("http://", "https://")): with urllib.request.urlopen(path_or_url) as r: # nosec - user-provided nb data = r.read().decode() return json.loads(data) with open(path_or_url) as f: return json.load(f) def _scan(nb): """Return (pinned_transformers, first_model_name) from the notebook source.""" pin = model = None for cell in nb.get("cells", []): if cell.get("cell_type") != "code": continue src = "".join(cell.get("source", [])) if pin is None: m = _PIN_RE.search(src) if m: pin = m.group(1) if model is None: m = _MODEL_RE.search(src) or _MODEL_NAME_RE.search(src) if m: model = m.group(1) return pin, model def main(): ap = argparse.ArgumentParser(prog = "unsloth-run") ap.add_argument("notebook") ap.add_argument("--out") ap.add_argument("--timeout", type = int, default = 3600) ap.add_argument("--transformers", dest = "tf") args = ap.parse_args() nb = _load(args.notebook) pin, model = _scan(nb) want = args.tf or pin or (compat.tier_for_model(model) if compat else None) sidecar = compat.sidecar_for(want) if (compat and want) else None # Materialise the notebook for nbconvert. With --out, stage input + result as # temp files next to the destination (same dir => atomic os.replace publish) # and publish only on success, so a failed run can't destroy the old output. tmp_dir = None tmp_files = [] publish_from = None if args.out: out_path = os.path.abspath(args.out) out_dir = os.path.dirname(out_path) or "." os.makedirs(out_dir, exist_ok = True) fd, src_path = tempfile.mkstemp(prefix = ".unsloth-run-in-", suffix = ".ipynb", dir = out_dir) with os.fdopen(fd, "w") as f: json.dump(nb, f) tmp_files.append(src_path) fd, publish_from = tempfile.mkstemp( prefix = ".unsloth-run-out-", suffix = ".ipynb", dir = out_dir ) os.close(fd) tmp_files.append(publish_from) elif args.notebook.startswith(("http://", "https://")): tmp_dir = tempfile.mkdtemp() src_path = os.path.join(tmp_dir, os.path.basename(args.notebook.split("?")[0])) with open(src_path, "w") as f: json.dump(nb, f) out_path = src_path else: src_path = args.notebook out_path = src_path env = dict(os.environ) env["UNSLOTH_NB_SHIM"] = "1" # enable safe-install for the notebook's cells # Per-run marker unless the caller pinned one: the shared default would leak # this run's transformers pin into concurrent/later runs. An empty marker # reads as "no pin", so pre-creating it is safe. marker = env.get("UNSLOTH_NB_TF_MARKER") if not marker: fd, marker = tempfile.mkstemp(prefix = ".unsloth-run-tfmarker-") os.close(fd) env["UNSLOTH_NB_TF_MARKER"] = marker tmp_files.append(marker) # The pip/uv shim writes the marker; pre-seed it too so the kernel agrees. if want: os.makedirs(os.path.dirname(marker) or ".", exist_ok = True) open(marker, "w").write(want) if sidecar: env["PYTHONPATH"] = sidecar + os.pathsep + env.get("PYTHONPATH", "") print(f"[unsloth-run] transformers {want} -> sidecar {sidecar}") elif want: print(f"[unsloth-run] transformers {want}: no sidecar (using base venv's newest)") else: print("[unsloth-run] no transformers pin/model tier detected; using base venv") nbconvert_out = publish_from if publish_from is not None else out_path cmd = [ "/opt/unsloth-venv/bin/jupyter", "nbconvert", "--to", "notebook", "--execute", f"--ExecutePreprocessor.timeout={args.timeout}", "--ExecutePreprocessor.kernel_name=python3", src_path, "--output", os.path.basename(nbconvert_out), "--output-dir", os.path.dirname(os.path.abspath(nbconvert_out)) or ".", ] print( "[unsloth-run] executing:", os.path.basename(args.notebook.split("?")[0]) if args.out else os.path.basename(src_path), ) try: rc = subprocess.call(cmd, env = env) if rc == 0 and publish_from is not None: os.replace(publish_from, out_path) finally: # Clean up the temp dir and any staging files (already gone when published). if tmp_dir is not None: shutil.rmtree(tmp_dir, ignore_errors = True) for p in tmp_files: try: os.remove(p) except OSError: pass sys.exit(rc) if __name__ == "__main__": main()