Studio: detect an interrupted dependency install instead of launching a backend that cannot import (#7492)

* Studio: detect an interrupted dependency install instead of launching a backend that cannot import

An installer killed part-way leaves a venv with a working CLI but without
studio.txt's dependencies. Nothing recorded that, so three separate places all
reported it healthy:

- the desktop preflight probed only `unsloth -h` (typer + rich) and a hardcoded
  desktop-capabilities dict, neither of which touches studio.backend, so it
  returned ManagedReady and spawned a backend that died on `import structlog`;
- setup.sh's fast path compared the installed unsloth version against PyPI,
  which matches on a half-built venv because unsloth is installed early, so
  `unsloth studio update` printed "up to date" and repaired nothing;
- start_managed_repair calls that update and then re-checks with the same blind
  probes, so Repair reported success without fixing anything.

install_python_stack.py now clears a completion manifest before the dependency
pass and writes it only after the final step. `unsloth studio verify-install`
and desktop-capabilities' new studio_install_ok field read it, the preflight
turns a false answer into ManagedStale so auto-repair runs, and setup.sh /
setup.ps1 gain an escape hatch next to the existing anyio one.

Separately, the wheel ships studio/ and studio.backend* but declared none of
their dependencies, so `unsloth train`, `export`, `chat`, `inference` and
`studio` all ended in a rich traceback after a plain pip install. structlog is
the only hard module-level import that chain reaches once starlette's
annotation-only import moves under TYPE_CHECKING, so it becomes a core
dependency and the rest of the server stack becomes a [studio] extra mirroring
studio.txt. The CLI import sites now report missing dependencies as a sentence
with two remedies.

Fixes #4701, #5260, #7147

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Match the trimmed comments merged on the pip branch

* Put the install manifest in the preflight fingerprint for PR #7492

The capability cache keyed the venv on pyvenv.cfg, uv.lock, requirements.txt,
the interpreter and site-packages/unsloth_cli/commands/studio.py, none of which
a repair touches when it only reinstalls studio.txt. So an entry cached while
the install was healthy stayed valid after the manifest was dropped, and the
probe returned Ready on exactly the half-built venv this is meant to catch.

* Address the review findings on PR #7492

Fail the install when the completion manifest cannot be written, instead of
exiting 0 without the record every later check requires, which is a repair
loop by construction.

Compare the version of the package the manifest names, so `studio update
--package X` does not read as a permanent version change.

Read the manifest from the venv that owns it when the CLI runs outside the
managed venv, and drop the dependency verdict in that case: the walk ran
against the wrong interpreter and says nothing about that venv.

Name the import that actually failed. `unsloth train` reaches torch through
the same guard, and the studio extra does not carry it, so recommending that
extra alone left the command failing in the same place.

* Declare click, which typer stopped providing, for PR #7492

unsloth_cli/commands/start.py imports click at module scope and
unsloth_cli/__init__.py imports that module, so every unsloth command needs
it. typer carried click through 0.19 and dropped it in 0.27, and the declared
floor is typer>=0.12.0, so a fresh resolve gets no click. On the published
wheel it still arrives because huggingface_hub requires click<9,>=8.4.2, which
is luck rather than a declaration. A wheel built from this branch's
dependency list has neither, and every command dies at import.

Verified: before, `unsloth --help` on a fresh venv raised ModuleNotFoundError
for click; after, it exits 0. The drift test now covers it.

* Keep a running backend from the previous app version manageable

The manageability bump gated two unrelated things through one constant. For
the managed CLI probe 2 is right: a CLI reporting 1 cannot answer
studio_install_ok. For a RUNNING backend it is wrong, because a process
already started cannot change what it reports, so bumping studio/backend/main.py
in lockstep does not help one the previous app version spawned.

That backend is proven ours by root id and ownership token, but
lifecycle_control_block_reason returned Unmanageable, and that branch never
calls adopt_verified_backend. has_owned_backend() stays false, so Repair falls
into block_external_conflict, which finds the same process and refuses: the app
could no longer stop a backend it owns the token for. The same regression in
backend.rs turned a terminal-launched same-root server from AttachedReady into
ExternalConflict.

Split the constant: DESKTOP_BACKEND_MANAGEABILITY_VERSION = 1 for the two
live-backend probes, DESKTOP_MANAGEABILITY_VERSION = 2 for the CLI probe. Every
real gate (protocol, auth, ownership, desktop-login, MIN_DESKTOP_BACKEND_VERSION)
is untouched, so an old backend still reaches OwnedStale, adopt, stop, repair.

Also stop the installer when the stale manifest cannot be removed. Windows
raises on a read-only or locked file, and the pass would then run behind a
marker that still names this version and these digests, so a run killed
part-way would verify as complete.

* Answer for the managed venv, not the one the CLI happens to run in

The guard matched ModuleNotFoundError.name, an import name, against
missing_requirements(), which returns distribution names. So a missing PyJWT
printed 'pip install jwt', and jwt, docx and fitz are each a real but unrelated
PyPI project (fitz is a neuroimaging workflow tool), so following the advice
installed the wrong package and left the backend just as broken. Map the import
to its distribution before deciding, and never offer the import itself.

install_state() verified the caller's own prefix. The wheel ships studio/, so a
CLI installed outside the managed venv always finds its own copy of the helper
first, and a healthy managed install reported studio_install_incomplete with a
missing list copied from the wrong venv. Selecting the root is not enough:
_installed_version() reads the running interpreter and req_root defaults to the
caller's studio.txt, so both checks still answered for the wrong venv. Hand
verify_install() that venv's own metadata, enumerated through
Distribution.discover(context = ...path), which does not fall back to sys.path.
The candidate order is untouched, so shadowed-tree detection is unchanged.

setup.ps1 replaces pip, torch and triton before install_python_stack.py runs,
so the manifest it drops is not dropped before the first mutation. A run killed
in between kept a marker that still verifies while torch was half-replaced;
drop it at the top of the dependency pass instead. setup.sh is unaffected, the
stack is the first thing its pass runs, and a test now pins both.

pip uninstall rewrites nothing that was fingerprinted, and cache_matches
re-reads the cached studio_install_ok rather than re-checking, so a venv that
lost a studio.txt package kept being served the healthy verdict. Fold a sorted
hash of the installed dist-info names into the marker hash.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* A missing manifest helper is a torn install, not an old one

studio/install_manifest.py ships in the same wheel as _studio_deps.py, so
nothing legitimately has one without the other: a CLI predating both never
reaches this code, and the desktop already calls such a CLI stale on
desktop_manageability_version.

Returning ok=true there reported a healthy install for a tree the package
update had half replaced, and the preflight then launched a backend whose
own run.py could be just as absent. Report it incomplete so repair runs.

* Tighten comments across the install-detection changes

* Validate Studio dependency readiness

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
This commit is contained in:
Daniel Han 2026-07-28 01:57:20 -07:00 committed by GitHub
commit 1781770bee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1877 additions and 61 deletions

View file

@ -0,0 +1,203 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Coverage for studio/install_manifest.py.
The manifest separates "the install finished" from "the installer was killed
part-way and the venv only looks fine". The CLI, setup.sh's fast path and the
Tauri preflight all read it, so a wrong answer either crashes the backend on
launch or forces needless reinstalls for everyone.
"""
from __future__ import annotations
import importlib.util
import json
import pathlib
import pytest
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
MODULE_PATH = REPO_ROOT / "studio" / "install_manifest.py"
def _load_module():
spec = importlib.util.spec_from_file_location("studio_install_manifest_under_test", MODULE_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
im = _load_module()
@pytest.fixture
def req_root(tmp_path: pathlib.Path) -> pathlib.Path:
"""A requirements tree whose studio.txt names one installed and one absent dist."""
root = tmp_path / "requirements"
root.mkdir()
(root / "studio.txt").write_text(
"# comment line\n\npytest\nunsloth-definitely-not-a-real-package\n",
encoding = "utf-8",
)
return root
@pytest.fixture
def install_root(tmp_path: pathlib.Path) -> pathlib.Path:
root = tmp_path / "venv"
root.mkdir()
return root
def test_parse_requirement_line_handles_the_shapes_studio_txt_uses():
assert im._parse_requirement_line("structlog>=24.1.0") == ("structlog", "", ">=24.1.0")
assert im._parse_requirement_line("matplotlib==3.10.9") == ("matplotlib", "", "==3.10.9")
assert im._parse_requirement_line("boto3>=1.34.0 # optional: S3") == (
"boto3",
"",
">=1.34.0",
)
assert im._parse_requirement_line("uvicorn[standard]") == ("uvicorn", "", "")
assert im._parse_requirement_line("# just a comment") is None
assert im._parse_requirement_line("") is None
assert im._parse_requirement_line("--index-url https://example.invalid") is None
name, marker, specifier = im._parse_requirement_line("pywin32 ; sys_platform == 'win32'")
assert name == "pywin32"
assert "sys_platform" in marker
assert specifier == ""
def test_missing_requirements_rejects_an_incompatible_installed_version(tmp_path):
req = tmp_path / "studio.txt"
req.write_text(
"matplotlib==3.10.9\nstructlog>=24.1.0\n",
encoding = "utf-8",
)
installed = {
"matplotlib": "3.9.0",
"structlog": "24.1.0",
}
assert im.missing_requirements(req, installed = installed) == ["matplotlib"]
def test_platform_gated_lines_are_skipped_when_the_marker_does_not_apply(tmp_path):
req = tmp_path / "studio.txt"
req.write_text(
"unsloth-not-real-a ; sys_platform == 'definitely-not-this-platform'\n"
"unsloth-not-real-b\n",
encoding = "utf-8",
)
missing = im.missing_requirements(req)
assert missing == ["unsloth-not-real-b"], (
"a requirement gated to another OS must not be reported missing, or every "
"install would look broken on the platforms that legitimately skip it"
)
def test_missing_requirements_matches_on_distribution_not_import_name(tmp_path):
# studio.txt lists PyJWT / python-docx / pymupdf, whose import names are
# jwt / docx / fitz, so matching on imports would look missing.
req = tmp_path / "studio.txt"
req.write_text("pytest\n", encoding = "utf-8")
assert im.missing_requirements(req) == []
def test_complete_install_verifies_ok(install_root, req_root):
im.write_manifest(root = install_root, req_root = req_root, package_name = "pytest")
state = im.verify_install(root = install_root, req_root = req_root, package_name = "pytest")
assert state["manifest_ok"] is True
assert state["deps_ok"] is False # the fake dist is intentionally absent
assert state["reason"] == "studio_deps_missing"
assert "unsloth-definitely-not-a-real-package" in state["missing"]
def test_missing_manifest_reports_incomplete(install_root, req_root):
state = im.verify_install(root = install_root, req_root = req_root, package_name = "pytest")
assert state["ok"] is False
assert state["manifest_ok"] is False
assert state["reason"] == "studio_install_incomplete"
def test_interrupted_install_leaves_no_manifest(install_root, req_root):
# remove_manifest() runs before the dependency pass, so a later kill cannot
# leave a stale-but-valid manifest behind.
im.write_manifest(root = install_root, req_root = req_root, package_name = "pytest")
assert im.manifest_path(install_root).is_file()
assert im.remove_manifest(install_root) is True
assert not im.manifest_path(install_root).is_file()
state = im.verify_install(root = install_root, req_root = req_root, package_name = "pytest")
assert state["reason"] == "studio_install_incomplete"
def test_remove_manifest_reports_whether_the_marker_is_really_gone(
install_root, req_root, monkeypatch
):
# Nothing to remove is success: a first install has no manifest yet.
assert im.remove_manifest(install_root) is True
# A surviving marker must be reported, not swallowed: the dependency pass
# would then run behind a manifest that still verifies, so a part-way kill
# looks complete.
im.write_manifest(root = install_root, req_root = req_root, package_name = "pytest")
path = im.manifest_path(install_root)
def _refuse(*_args, **_kwargs):
raise PermissionError(13, "Access is denied")
monkeypatch.setattr(pathlib.Path, "unlink", _refuse)
assert im.remove_manifest(install_root) is False
monkeypatch.undo()
# The stale marker still verifies, which is why the installer has to stop.
assert path.is_file()
state = im.verify_install(root = install_root, req_root = req_root, package_name = "pytest")
assert state["manifest_ok"] is True
def test_schema_bump_invalidates_an_old_manifest(install_root, req_root):
im.write_manifest(root = install_root, req_root = req_root, package_name = "pytest")
path = im.manifest_path(install_root)
data = json.loads(path.read_text(encoding = "utf-8"))
data["schema"] = im.MANIFEST_SCHEMA + 1
path.write_text(json.dumps(data), encoding = "utf-8")
state = im.verify_install(root = install_root, req_root = req_root, package_name = "pytest")
assert state["reason"] == "studio_install_manifest_schema"
def test_package_upgrade_invalidates_the_manifest(install_root, req_root):
im.write_manifest(root = install_root, req_root = req_root, package_name = "pytest")
path = im.manifest_path(install_root)
data = json.loads(path.read_text(encoding = "utf-8"))
data["package_version"] = "0.0.0-not-the-installed-version"
path.write_text(json.dumps(data), encoding = "utf-8")
state = im.verify_install(root = install_root, req_root = req_root, package_name = "pytest")
assert state["reason"] == "studio_install_version_changed"
def test_verify_follows_the_package_the_manifest_names(install_root, req_root):
# `studio update --package X` records X. Checking unsloth's version instead
# would report a change on every probe and repair for ever.
im.write_manifest(root = install_root, req_root = req_root, package_name = "pytest")
state = im.verify_install(
root = install_root,
req_root = req_root,
package_name = "unsloth-definitely-not-a-real-package",
)
assert state["manifest_ok"] is True
def test_edited_requirements_invalidate_the_manifest(install_root, req_root):
# The --local dev path: an edited studio.txt must re-run the dependency
# pass, not sit behind setup.sh's "up to date" fast path.
im.write_manifest(root = install_root, req_root = req_root, package_name = "pytest")
(req_root / "studio.txt").write_text("pytest\nrich\n", encoding = "utf-8")
state = im.verify_install(root = install_root, req_root = req_root, package_name = "pytest")
assert state["reason"] == "studio_install_requirements_changed"
def test_unwritable_root_degrades_to_incomplete(tmp_path, req_root):
missing_root = tmp_path / "does" / "not" / "exist"
assert im.write_manifest(root = missing_root, req_root = req_root) is None
state = im.verify_install(root = missing_root, req_root = req_root, package_name = "pytest")
assert state["ok"] is False

View file

@ -0,0 +1,99 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""setup.sh / setup.ps1 must not skip the dependency pass on a half-built venv.
Both short-circuit all dependency work when the installed unsloth version equals
PyPI's latest, which is true on an interrupted install: unsloth goes in early and
studio.txt never finishes. So update, and the desktop Repair button behind it,
said "up to date" while the server kept dying on `import structlog`.
That branch only runs for a non-local update, which reinstalls from PyPI and
clobbers the tree under test, so assert the guard structurally instead.
"""
from __future__ import annotations
import pathlib
import re
import pytest
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
SETUP_SH = REPO_ROOT / "studio" / "setup.sh"
SETUP_PS1 = REPO_ROOT / "studio" / "setup.ps1"
@pytest.mark.parametrize("script", [SETUP_SH, SETUP_PS1], ids = ["setup.sh", "setup.ps1"])
def test_fast_path_consults_the_install_manifest(script: pathlib.Path):
text = script.read_text(encoding = "utf-8")
assert "install_manifest" in text, (
f"{script.name} no longer consults studio/install_manifest.py. Without it "
"the 'up to date' fast path skips the dependency pass on an interrupted "
"install, and `unsloth studio update` becomes a silent no-op."
)
assert "verify_install" in text, (
f"{script.name} must call install_manifest.verify_install() so the check "
"matches what `unsloth studio verify-install` and the desktop preflight use."
)
@pytest.mark.parametrize("script", [SETUP_SH, SETUP_PS1], ids = ["setup.sh", "setup.ps1"])
def test_guard_can_still_force_the_dependency_pass(script: pathlib.Path):
"""The guard has to clear the skip flag, not merely log a warning."""
text = script.read_text(encoding = "utf-8")
if script.name.endswith(".ps1"):
pattern = r"studio install incomplete[\s\S]{0,200}?\$SkipPythonDeps\s*=\s*\$false"
else:
pattern = r"studio install incomplete[\s\S]{0,200}?_SKIP_PYTHON_DEPS=false"
assert re.search(pattern, text), (
f"{script.name} detects an incomplete install but does not clear the "
"skip flag, so the dependency pass would still be skipped."
)
def test_ps1_drops_the_manifest_before_its_first_install():
"""Nothing may mutate the venv while the marker still says "install finished".
install_python_stack.py drops it before its own dependency pass, which is
enough for setup.sh: the stack is the first thing that pass runs. setup.ps1
replaces pip, torch and triton first, so a run killed there would leave a
manifest that still verifies and a venv with half a PyTorch.
"""
text = SETUP_PS1.read_text(encoding = "utf-8")
pass_start = text.index("if (-not $SkipPythonDeps) {")
removal = text.find("remove_manifest", pass_start)
first_install = text.index("Fast-Install", pass_start)
stack = text.index(r'python "$PSScriptRoot\install_python_stack.py"', pass_start)
assert removal != -1, (
"setup.ps1 never drops the install manifest; install_python_stack.py "
"only does so after setup.ps1 has already replaced pip and torch"
)
assert removal < first_install < stack, (
"setup.ps1 must invalidate the install manifest before its first "
"Fast-Install, not leave it to install_python_stack.py"
)
def test_sh_dependency_pass_mutates_nothing_before_the_stack():
"""setup.sh relies on install_python_stack.py dropping the marker, which only
holds while the stack is the first thing its dependency pass runs."""
text = SETUP_SH.read_text(encoding = "utf-8")
pass_start = text.index('if [ "$_SKIP_PYTHON_DEPS" = false ]')
body = text[pass_start : text.index("install_python_stack", pass_start)]
assert "fast_install" not in body and "pip install" not in body, (
"setup.sh installs something before install_python_stack.py drops the "
"manifest, so an interrupted run would keep a marker that verifies"
)
def test_sh_guard_runs_before_the_skip_decision():
text = SETUP_SH.read_text(encoding = "utf-8")
guard = text.find("studio install incomplete")
decision = text.find('if [ "$_SKIP_PYTHON_DEPS" = false ]')
assert guard != -1 and decision != -1
assert guard < decision, (
"the incomplete-install guard must run before setup.sh acts on "
"_SKIP_PYTHON_DEPS, otherwise it can never change the outcome"
)

View file

@ -0,0 +1,273 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Coverage for unsloth_cli/_studio_deps.py.
Two things have to be right for the CLI half of the install check.
It must describe the venv it was *asked* about. The wheel ships studio/, so a
CLI installed outside the managed venv always finds its own copy of the manifest
helper, and would otherwise report on its own prefix: a healthy managed install
comes back "incomplete", a broken one comes back with the wrong missing list.
And it must name the *distribution* to install rather than the import that
failed. `pip install jwt` / `docx` / `fitz` all succeed and install unrelated
PyPI projects, leaving the backend just as broken as before.
"""
from __future__ import annotations
import importlib.util
import io
import json
import contextlib
import pathlib
import shutil
import sys
import pytest
import typer
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
DEPS_PATH = REPO_ROOT / "unsloth_cli" / "_studio_deps.py"
MANIFEST_PATH = REPO_ROOT / "studio" / "install_manifest.py"
REQUIREMENTS = REPO_ROOT / "studio" / "backend" / "requirements"
def _load(path: pathlib.Path, name: str):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
_MANIFEST = _load(MANIFEST_PATH, "install_manifest_for_deps_test")
def _studio_distributions() -> list:
lines = (REQUIREMENTS / "studio.txt").read_text(encoding = "utf-8").splitlines()
parsed = [_MANIFEST._parse_requirement_line(line) for line in lines]
return [name for name, _, _ in (p for p in parsed if p is not None)]
def _studio_distribution_versions() -> dict:
versions = {}
lines = (REQUIREMENTS / "studio.txt").read_text(encoding = "utf-8").splitlines()
for parsed in (_MANIFEST._parse_requirement_line(line) for line in lines):
if parsed is None:
continue
name, _marker, specifier = parsed
version = "1.0.0"
for part in specifier.split(","):
if part.startswith("=="):
version = part[2:]
break
if part.startswith(">="):
version = part[2:]
versions[name] = version
return versions
def _make_venv(
root: pathlib.Path,
*,
unsloth_version: str,
distributions,
extra_requirement = "",
):
"""A venv tree: pyvenv.cfg, the shipped studio/ package and .dist-info dirs."""
site_packages = root / "lib" / "python3.11" / "site-packages"
(site_packages / "studio" / "backend").mkdir(parents = True)
shutil.copy(MANIFEST_PATH, site_packages / "studio" / "install_manifest.py")
shutil.copytree(REQUIREMENTS, site_packages / "studio" / "backend" / "requirements")
if extra_requirement:
studio_txt = site_packages / "studio" / "backend" / "requirements" / "studio.txt"
studio_txt.write_text(
studio_txt.read_text(encoding = "utf-8") + extra_requirement, encoding = "utf-8"
)
(root / "pyvenv.cfg").write_text("home = /usr/bin\n", encoding = "utf-8")
studio_versions = _studio_distribution_versions()
for name in [*distributions, "unsloth"]:
version = unsloth_version if name == "unsloth" else studio_versions.get(name, "1.0.0")
dist_info = site_packages / f"{name.replace('-', '_')}-{version}.dist-info"
dist_info.mkdir()
(dist_info / "METADATA").write_text(
f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n",
encoding = "utf-8",
)
return site_packages
def _write_manifest(root: pathlib.Path, site_packages: pathlib.Path, version: str):
(root / _MANIFEST.MANIFEST_NAME).write_text(
json.dumps(
{
"schema": _MANIFEST.MANIFEST_SCHEMA,
"package": "unsloth",
"package_version": version,
"requirement_files": _MANIFEST.requirement_digests(
site_packages / "studio" / "backend" / "requirements",
),
}
),
encoding = "utf-8",
)
@pytest.fixture
def cross_venv(tmp_path, monkeypatch):
"""`unsloth studio verify-install` run from a CLI outside the managed venv.
Returns a callable: build the managed venv, then ask about it.
"""
def build(
*,
managed_version = "2026.6.1",
caller_version = "2026.7.9",
managed_distributions = None,
extra_requirement = "",
with_manifest = True,
):
caller = tmp_path / "caller_venv"
caller_site = _make_venv(caller, unsloth_version = caller_version, distributions = [])
managed = tmp_path / "studio_home" / "unsloth_studio"
managed_site = _make_venv(
managed,
unsloth_version = managed_version,
distributions = _studio_distributions()
if managed_distributions is None
else managed_distributions,
extra_requirement = extra_requirement,
)
if with_manifest:
_write_manifest(managed, managed_site, managed_version)
(caller_site / "unsloth_cli").mkdir(parents = True)
shutil.copy(DEPS_PATH, caller_site / "unsloth_cli" / "_studio_deps.py")
monkeypatch.setattr(sys, "prefix", str(caller))
deps = _load(caller_site / "unsloth_cli" / "_studio_deps.py", "studio_deps_cross_venv")
return deps.install_state(extra_roots = (managed,))
return build
def test_a_healthy_managed_venv_is_not_reported_incomplete(cross_venv):
"""The caller's own prefix has no manifest and none of studio.txt, so
describing it instead sends a working install through a needless repair."""
state = cross_venv()
assert state["ok"] is True, state
assert state["reason"] is None
assert state["missing"] == []
def test_a_newer_caller_does_not_look_like_a_changed_managed_version(cross_venv):
"""The version and requirement digests must come from the managed venv too:
reading them here compares two unrelated installs."""
state = cross_venv(managed_version = "2026.1.1", caller_version = "2026.12.31")
assert state["ok"] is True, state
def test_a_managed_venv_missing_a_boot_dep_names_that_dep(cross_venv):
"""The other direction: report what is actually absent over there."""
state = cross_venv(
managed_distributions = [d for d in _studio_distributions() if d != "fastmcp"],
)
assert state["ok"] is False
assert state["reason"] == "studio_deps_missing"
assert state["missing"] == ["fastmcp"], state
def test_an_unfinished_managed_install_is_still_reported_incomplete(cross_venv):
state = cross_venv(with_manifest = False)
assert state["ok"] is False
assert state["reason"] == "studio_install_incomplete"
# ── import name vs distribution name ─────────────────────────────────
@pytest.fixture
def deps():
return _load(DEPS_PATH, "studio_deps_under_test")
def _remediation(deps, trigger: str, studio_missing) -> str:
deps._missing_studio_packages = lambda: list(studio_missing)
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr), pytest.raises(typer.Exit):
with deps.studio_backend_imports("unsloth studio"):
raise ModuleNotFoundError(f"No module named '{trigger}'", name = trigger)
return stderr.getvalue()
@pytest.mark.parametrize(
"trigger, distribution",
[("jwt", "pyjwt"), ("docx", "python-docx"), ("fitz", "pymupdf")],
)
def test_a_missing_studio_package_is_named_by_its_distribution(deps, trigger, distribution):
"""`pip install jwt` installs a different JWT library and repairs nothing."""
output = _remediation(deps, trigger, [distribution])
assert f"pip install {trigger}" not in output, output
assert distribution in output
assert "unsloth studio update" in output
def test_a_normalised_name_still_counts_as_a_studio_dependency(deps):
"""studio.txt writes huggingface-hub; the import is huggingface_hub."""
output = _remediation(deps, "huggingface_hub", ["huggingface-hub"])
assert "Install it:" not in output, output
assert "also missing:" not in output, output
def test_a_missing_submodule_is_traced_to_its_installable_package(deps):
"""exc.name is dotted when the top level survived a partial install, and
`pip install fastmcp.server` is not a package name at all."""
output = _remediation(deps, "fastmcp.server", ["fastmcp"])
assert "fastmcp.server" not in output.split("Install it:")[-1], output
assert "Install it:" not in output, output
assert "unsloth studio update" in output
def test_a_non_studio_dependency_keeps_its_own_install_line(deps):
"""train reaches torch through the same wrapped import and the studio extra
does not carry it."""
output = _remediation(deps, "torch", ["pyjwt"])
assert "pip install torch" in output
assert "also missing: pyjwt" in output
def test_studio_only_guard_preserves_non_studio_failures(deps):
deps._missing_studio_packages = lambda: ["pyjwt"]
with pytest.raises(ModuleNotFoundError):
with deps.studio_backend_imports("unsloth inference", studio_only = True):
raise ModuleNotFoundError("No module named 'mlx'", name = "mlx")
def test_the_import_map_only_names_studio_distributions():
"""Drift guard: an entry pointing at a dropped requirement is dead advice."""
known = {deps_name.lower() for deps_name in _studio_distributions()}
module = _load(DEPS_PATH, "studio_deps_map_check")
for import_name, distribution in module._IMPORT_TO_DISTRIBUTION.items():
assert distribution.lower() in known, (
f"_IMPORT_TO_DISTRIBUTION maps {import_name} to {distribution}, "
"which studio.txt no longer requires"
)
def test_a_torn_tree_without_the_manifest_helper_is_incomplete(tmp_path, monkeypatch):
"""studio/install_manifest.py ships in the same wheel as _studio_deps.py, so
only a torn install has one without the other. Answering yes here launches a
backend whose own files may be just as absent."""
caller = tmp_path / "caller_venv"
site_packages = caller / "lib" / "python3.11" / "site-packages"
(site_packages / "unsloth_cli").mkdir(parents = True)
shutil.copy(DEPS_PATH, site_packages / "unsloth_cli" / "_studio_deps.py")
(caller / "pyvenv.cfg").write_text("home = /usr/bin\n", encoding = "utf-8")
monkeypatch.setattr(sys, "prefix", str(caller))
deps = _load(site_packages / "unsloth_cli" / "_studio_deps.py", "studio_deps_torn_tree")
state = deps.install_state()
assert state["ok"] is False, state
assert state["reason"] == "studio_install_manifest_missing"

View file

@ -0,0 +1,85 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""The studio extra must mirror studio/backend/requirements/studio.txt.
Nothing else keeps them in sync, and drift reintroduces #4701 / #5260 / #7147.
"""
from __future__ import annotations
import pathlib
import sys
import pytest
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
PYPROJECT = REPO_ROOT / "pyproject.toml"
STUDIO_TXT = REPO_ROOT / "studio" / "backend" / "requirements" / "studio.txt"
# Imported at module scope by the chain every CLI command walks: structlog via
# studio.backend, click via unsloth_cli/commands/start.py.
CORE_RUNTIME_PACKAGES = ("structlog", "click")
def _load_pyproject() -> dict:
if sys.version_info >= (3, 11):
import tomllib
else:
tomllib = pytest.importorskip("tomli")
return tomllib.loads(PYPROJECT.read_text(encoding = "utf-8"))
def _requirement_lines(path: pathlib.Path) -> list[str]:
out = []
for line in path.read_text(encoding = "utf-8").splitlines():
text = line.split("#", 1)[0].strip()
if text and not text.startswith("-"):
out.append(text)
return out
def _normalise(name: str) -> str:
"""PEP 503 normalisation, so PyJWT/pyjwt and nest_asyncio/nest-asyncio match."""
head = name
for sep in ("===", "==", ">=", "<=", "~=", "!=", ">", "<", "[", ";", " "):
idx = head.find(sep)
if idx > 0:
head = head[:idx]
return head.strip().lower().replace("_", "-").replace(".", "-")
def test_studio_extra_exists():
extras = _load_pyproject()["project"]["optional-dependencies"]
assert "studio" in extras, (
"pyproject.toml has no `studio` extra. The wheel ships studio/ and "
"studio.backend*, so their dependencies need a pip-installable home."
)
def test_studio_extra_matches_requirements_file():
extras = _load_pyproject()["project"]["optional-dependencies"]
extra = sorted(_normalise(entry) for entry in extras["studio"])
required = sorted(_normalise(entry) for entry in _requirement_lines(STUDIO_TXT))
missing = sorted(set(required) - set(extra))
surplus = sorted(set(extra) - set(required))
assert not missing, (
f"studio.txt lists {missing} but the `studio` extra does not. "
'`pip install "unsloth[studio]"` would build a venv the Studio server '
"cannot boot in. Add them to [project.optional-dependencies] studio."
)
assert not surplus, (
f"The `studio` extra lists {surplus} but studio.txt does not. "
"Remove them, or add them to studio.txt if install.sh needs them too."
)
@pytest.mark.parametrize("package", CORE_RUNTIME_PACKAGES)
def test_cli_runtime_packages_are_core_dependencies(package):
core = [_normalise(entry) for entry in _load_pyproject()["project"]["dependencies"]]
assert _normalise(package) in core, (
f"{package} is imported at module scope by the studio.backend chain "
f"`unsloth train` / `unsloth export` walk, so a plain `pip install "
f"unsloth` must provide it or they die with ModuleNotFoundError."
)