unsloth/tests/python/test_docker_nb_view_ownership.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

118 lines
4.2 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0
"""The categorized notebook VIEW may only delete the links it created.
`unsloth_nb_view.py` rebuilds "/workspace/Unsloth Notebooks" on every boot, and
that directory is also JupyterLab's landing dir, so `_clear_view()` promises to
remove only the tool's own symlinks. Every link the tool creates points at
DEST/nb/<file>, but the ownership predicate accepted ANY target under DEST, so a
user's own symlink into the notebooks checkout -- e.g. a shortcut to their own
notebook saved beside it, which the sync script explicitly supports ("kept
existing user file" / "In DEST but never recorded") -- was classified as
tool-owned and deleted on the next boot.
Behavioural: builds a real DEST/VIEW pair on disk and runs build_view twice.
No docker, no network.
"""
from __future__ import annotations
import importlib.util
import os
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
VIEW_PATH = REPO_ROOT / "docker" / "unsloth_nb_view.py"
README = (
"### Main Notebooks\n"
"[Llama](nb/Llama3_2_%281B_and_3B%29_Conversational.ipynb)\n"
"### Gemma\n"
"[Gemma](nb/Gemma3_%284B%29.ipynb)\n"
)
@pytest.fixture(scope = "module")
def view_mod():
assert VIEW_PATH.is_file(), f"missing {VIEW_PATH}"
spec = importlib.util.spec_from_file_location("unsloth_nb_view_under_test", VIEW_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def tree(tmp_path: Path):
dest = tmp_path / "unsloth-notebooks"
view = tmp_path / "Unsloth Notebooks"
(dest / "nb").mkdir(parents = True)
view.mkdir()
for name in ("Llama3_2_(1B_and_3B)_Conversational.ipynb", "Gemma3_(4B).ipynb"):
(dest / "nb" / name).write_text("{}", encoding = "utf-8")
(dest / "README.md").write_text(README, encoding = "utf-8")
# The user's own notebook, saved inside the checkout (supported by the sync
# script), plus their own folder of shortcuts in the landing dir.
(dest / "my_work").mkdir()
(dest / "my_work" / "experiment.ipynb").write_text("{}", encoding = "utf-8")
return dest, view
def link(target: Path, at: Path) -> None:
at.parent.mkdir(parents = True, exist_ok = True)
os.symlink(os.path.relpath(target, at.parent), at)
def test_a_user_link_to_their_own_file_in_the_checkout_survives(view_mod, tree):
dest, view = tree
own = view / "00 My favourites" / "experiment.ipynb"
link(dest / "my_work" / "experiment.ipynb", own)
view_mod.build_view(str(dest), str(view))
assert os.path.islink(own), (
"a symlink the user created in the landing dir, pointing at their own "
"file inside the notebooks checkout, was deleted by _clear_view"
)
assert os.path.realpath(own) == os.path.realpath(dest / "my_work" / "experiment.ipynb")
def test_a_user_link_outside_the_checkout_survives(view_mod, tree, tmp_path: Path):
dest, view = tree
outside = tmp_path / "datasets"
outside.mkdir()
own = view / "datasets"
link(outside, own)
view_mod.build_view(str(dest), str(view))
assert os.path.islink(own)
def test_the_tools_own_stale_links_are_still_cleaned_up(view_mod, tree):
dest, view = tree
view_mod.build_view(str(dest), str(view))
generated = view / "02 Gemma" / "Gemma3_(4B).ipynb"
assert os.path.islink(generated)
# Upstream drops the notebook: its generated link (now stale, and pointing
# into DEST/nb) has to go, and the emptied folder with it.
(dest / "nb" / "Gemma3_(4B).ipynb").unlink()
(dest / "README.md").write_text(
"### Main Notebooks\n[Llama](nb/Llama3_2_%281B_and_3B%29_Conversational.ipynb)\n",
encoding = "utf-8",
)
view_mod.build_view(str(dest), str(view))
assert not os.path.islink(generated) and not os.path.exists(generated)
assert not (view / "02 Gemma").exists()
def test_a_rebuild_is_stable_for_the_links_it_owns(view_mod, tree):
dest, view = tree
view_mod.build_view(str(dest), str(view))
first = sorted(str(p.relative_to(view)) for p in view.rglob("*"))
view_mod.build_view(str(dest), str(view))
assert sorted(str(p.relative_to(view)) for p in view.rglob("*")) == first