unsloth/docker/unsloth_run.py
2026-07-19 15:32:20 +00:00

162 lines
6 KiB
Python

#!/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 <notebook.ipynb | URL> [--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()