unsloth/docker/unsloth_nb_view.py
Daniel Han 5906a9feb6 docker: close four holes the review found
docker-publish.yml: the llama.cpp tag resolver was the one step in the prepare
job still using `curl | sed` without pipefail. The runner's default `bash -e`
shell takes sed's exit status, so an unreachable github.com left TAG empty and
the step published the mutable `latest`. Both arch legs re-resolve that through
fetch_llama_prebuilt.py and Dockerfile.studio resolves it a third time, so a
release cut mid-run can put different llama.cpp bundles under one manifest.
Capture the redirect first and fail the job when it is missing or does not land
on a release tag, matching the three ref resolvers below it.

unsloth_nb_strip_colab.py: strip_notebook read, parsed and then unconditionally
os.replace'd. The refresh child re-arms finalize after the entrypoint has execed
the container command, so JupyterLab is already serving the tree and a save
landing in that window was destroyed, after which migrate recorded the cleaned
hash and marked the notebook pristine forever. Re-read the hash once the staged
copy is complete and drop it when the file moved, the same rule the refresh
publish in unsloth_sync_notebooks.sh already follows.

unsloth_nb_view.py: ownership for the view teardown accepted any symlink target
under DEST, but every link the tool creates points at DEST/nb. A shortcut the
user made in the landing dir to their own file elsewhere in the checkout was
therefore classified as ours and deleted on the next boot. Key ownership on
DEST/nb instead.

cellNav.ts: the edit-mode boundary test compared the cursor line against
editor.lineCount, both logical, while JupyterLab wraps markdown and raw editors
by default (StaticNotebook.defaultEditorConfig). A one-line markdown header
renders as several visual rows, so every arrow left the cell and the wrapped
rows could not be reached. Ask CodeMirror whether it can still move one visual
line (EditorView.moveVertically, compared by coordsAtPos top) and keep the
logical test as the fallback for a non-CodeMirror editor.

New tests: 12 passed / 8 failed before, 20 passed / 0 failed after.
2026-07-27 16:06:56 +00:00

242 lines
9.5 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0
# Build a categorized, Colab-like folder VIEW of the Unsloth notebooks.
#
# The canonical notebooks live flat under DEST/nb/<file>.ipynb (kept by
# unsloth_sync_notebooks.sh). This builds a sibling dir of *relative symlinks*
# grouped into folders mirroring the README headers:
# <VIEW>/01 Main Notebooks/Llama3_2_(1B_and_3B)_Conversational.ipynb
# <VIEW>/99 Other Notebooks/<anything on disk not linked from the README>
# Symlinks so real files never move (the sync state machine skips them); the VIEW
# is a disposable sibling of DEST, rebuilt on every boot.
#
# Categorization rules:
# * Section = nearest preceding `###` header; a header repeated across domains
# merges into one folder (first order).
# * Folder names cleaned (dashes/slashes -> spaces) and numbered `NN ` by first
# appearance so JupyterLab's sort keeps README order; "Other" is last.
# * A notebook linked under several sections lands in its first.
# * AMD-*.ipynb hidden unless --amd; unlinked nb/*.ipynb go to "Other Notebooks".
#
# Usage:
# unsloth_nb_view.py <DEST> <VIEW> [--amd] build the symlink view
# unsloth_nb_view.py <DEST> --print [--amd] print "section\tfile" rows
# Exits nonzero on error (caller falls back to the raw tree).
import argparse
import os
import re
import sys
import urllib.parse
# nb/<file>.ipynb in any link form. Filenames use [\w.()-] plus %-escapes.
_NB_RE = re.compile(r"nb/([\w.()%\-]+?\.ipynb)")
_OTHER = "Other Notebooks"
def clean_section(title):
"""README header text -> a filesystem-friendly folder label."""
title = title.strip().strip("#").strip()
# Strip a leading emoji/symbol run so the folder label is clean text.
title = re.sub(r"^[^\w]+", "", title)
title = title.replace("-", " ").replace("/", " ")
title = re.sub(r"\s+", " ", title).strip()
return title
def parse_readme(readme_path):
"""Return an ordered list of (section_label, filename) pairs.
A notebook is intentionally cross-listed under several `###` headers in the
README (e.g. ModernBert under both "Embedding" and "BERT"), so that every
header becomes a populated folder. We therefore dedup per (section, file) --
a file shows up once in EACH section that lists it -- rather than globally.
Repeated headers across the Fine-tuning / Kaggle / AMD domains share a label
and so merge into one folder downstream.
filename is the urldecoded basename under nb/ (literal parens, matching disk).
"""
with open(readme_path, "r", encoding = "utf-8") as f:
text = f.read()
rows = []
seen_pairs = set()
section = None
# Reset on ANY markdown heading, not just `###`: `#`/`##` domain headers carry
# their own nb/*.ipynb tables, so matching only `###` mis-filed those links.
for line in text.splitlines():
m = re.match(r"^#{1,6}\s+(.*)$", line)
if m:
section = clean_section(m.group(1))
continue
if section is None:
continue
for raw in _NB_RE.findall(line):
fname = urllib.parse.unquote(raw)
key = (section, fname)
if key in seen_pairs:
continue
seen_pairs.add(key)
rows.append((section, fname))
return rows
def _ordered_sections(rows):
"""Section labels in first-appearance order, with Other Notebooks last."""
order = []
for section, _ in rows:
if section not in order:
order.append(section)
# Force the catch-all to the end even if the README defines it earlier.
order = [s for s in order if s != _OTHER] + [_OTHER]
return order
def build_view(
dest,
view,
amd = False,
):
nb_dir = os.path.join(dest, "nb")
readme = os.path.join(dest, "README.md")
if not os.path.isdir(nb_dir):
raise SystemExit(f"no nb/ dir under {dest}")
# The VIEW may be a symlink to mounted storage; build inside its target.
if os.path.islink(view):
resolved = os.path.realpath(view)
if not os.path.isdir(resolved):
raise SystemExit(f"view symlink has no directory target: {view} -> {resolved}")
view = resolved
rows = parse_readme(readme) if os.path.isfile(readme) else []
def allowed(fname):
return amd or not fname.startswith("AMD-")
# section -> [filenames], preserving README order, AMD-filtered, on-disk only.
by_section = {}
placed = set()
for section, fname in rows:
if not allowed(fname):
continue
if not os.path.isfile(os.path.join(nb_dir, fname)):
continue
by_section.setdefault(section, []).append(fname)
placed.add(fname)
# Everything on disk that the README never linked -> Other Notebooks.
for fname in sorted(os.listdir(nb_dir)):
if not fname.endswith(".ipynb"):
continue
if fname in placed or not allowed(fname):
continue
by_section.setdefault(_OTHER, []).append(fname)
order = [s for s in _ordered_sections(rows) if s in by_section]
if _OTHER in by_section and _OTHER not in order:
order.append(_OTHER)
# Rebuild VIEW: drop our own symlinks/empty folders, never the user's files
# (VIEW is also JupyterLab's landing dir). Ownership is keyed on DEST/nb --
# the only place our links ever point -- so a shortcut the user made to their
# own file elsewhere in the checkout survives the rebuild.
nb_real = os.path.realpath(nb_dir)
_clear_view(view, nb_real)
os.makedirs(view, exist_ok = True)
n_links = 0
for i, section in enumerate(order, start = 1):
folder = os.path.join(view, f"{i:02d} {section}")
os.makedirs(folder, exist_ok = True)
for fname in by_section[section]:
link = os.path.join(folder, fname)
target = os.path.join(nb_dir, fname)
rel = os.path.relpath(target, folder) # ../../unsloth-notebooks/nb/<file>
try:
if os.path.islink(link) and _points_into(link, nb_real):
os.remove(link) # replace our own stale symlink
elif os.path.islink(link) or os.path.exists(link):
# a real user file occupies this name: keep it, skip linking.
print(f"[unsloth-nb] view: keep user file, skip link {fname}", file = sys.stderr)
continue
os.symlink(rel, link)
n_links += 1
except OSError as e:
print(f"[unsloth-nb] view: skip {fname}: {e}", file = sys.stderr)
return len(order), n_links
def _points_into(link, nb_real):
"""True when a symlink resolves into DEST/nb, the dir we link FROM.
Every link this tool creates points at DEST/nb/<file>, so this is the
ownership test for cleanup: a user's own symlink (to a dataset, project,
mounted dir, or their own notebook saved elsewhere in the checkout) resolves
outside DEST/nb and must survive a rebuild -- matching on all of DEST deleted
those. realpath resolves a broken link's path string too, so stale links to
since-removed notebooks are still recognised as ours.
"""
try:
target = os.path.realpath(link)
except OSError:
return False
return target == nb_real or target.startswith(nb_real + os.sep)
def _clear_view(path, nb_real):
# Tear down a previously built VIEW in place. It is also JupyterLab's landing
# dir, so user files/symlinks must survive: unlink only symlinks we own (see
# _points_into) and rmdir only emptied folders. The VIEW root is never unlinked.
if os.path.islink(path) or not os.path.isdir(path):
return
for root, dirs, files in os.walk(path, topdown = False):
for name in files:
p = os.path.join(root, name)
if os.path.islink(p) and _points_into(p, nb_real):
try:
os.remove(p)
except OSError:
pass
# a regular file / user symlink here is user-created -> keep it
for name in dirs:
p = os.path.join(root, name)
try:
if os.path.islink(p):
if _points_into(p, nb_real):
os.remove(p) # our symlinked dir: unlink, never recurse
else:
os.rmdir(p) # succeeds only if we emptied it
except OSError:
pass # holds user files -> keep
def main(argv):
ap = argparse.ArgumentParser(description = "Build the categorized notebook view.")
ap.add_argument("dest", help = "notebooks dir (contains README.md and nb/)")
ap.add_argument("view", nargs = "?", help = "output view dir (omit with --print)")
ap.add_argument("--amd", action = "store_true", help = "include AMD-* notebooks")
ap.add_argument(
"--print",
dest = "do_print",
action = "store_true",
help = "print section<TAB>file rows instead of building",
)
args = ap.parse_args(argv)
if args.do_print:
for section, fname in parse_readme(os.path.join(args.dest, "README.md")):
if args.amd or not fname.startswith("AMD-"):
print(f"{section}\t{fname}")
return 0
if not args.view:
ap.error("view dir is required unless --print is given")
n_sections, n_links = build_view(args.dest, args.view, amd = args.amd)
print(f"[unsloth-nb] view: {n_links} notebooks in {n_sections} folders -> {args.view}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))