323 lines
13 KiB
Python
323 lines
13 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
import importlib
|
|
import json
|
|
import sys
|
|
from types import SimpleNamespace
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import typer
|
|
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
if str(_REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_REPO_ROOT))
|
|
|
|
|
|
def _missing_structlog():
|
|
raise ModuleNotFoundError("No module named 'structlog'", name = "structlog")
|
|
|
|
|
|
def test_desktop_runtime_check_reports_missing_dependency_as_json(monkeypatch, capsys):
|
|
studio = importlib.import_module("unsloth_cli.commands.studio")
|
|
monkeypatch.setattr(studio, "_load_run_module", _missing_structlog)
|
|
|
|
with pytest.raises(typer.Exit) as exited:
|
|
studio.desktop_runtime_check(_json_output = True)
|
|
|
|
assert exited.value.exit_code == 1
|
|
assert json.loads(capsys.readouterr().out) == {
|
|
"runtime_ready": False,
|
|
"reason": "missing_dependency",
|
|
"module": "structlog",
|
|
}
|
|
|
|
|
|
def test_desktop_runtime_check_reports_success(monkeypatch, capsys):
|
|
studio = importlib.import_module("unsloth_cli.commands.studio")
|
|
monkeypatch.setattr(studio, "_load_run_module", lambda: object())
|
|
monkeypatch.setattr(studio, "_missing_studio_requirement", lambda _run_mod: None)
|
|
|
|
studio.desktop_runtime_check(_json_output = True)
|
|
|
|
assert json.loads(capsys.readouterr().out) == {"runtime_ready": True}
|
|
|
|
|
|
def test_desktop_runtime_check_catches_later_startup_dependency(monkeypatch, capsys, tmp_path):
|
|
studio = importlib.import_module("unsloth_cli.commands.studio")
|
|
backend = tmp_path / "backend"
|
|
requirements = backend / "requirements"
|
|
requirements.mkdir(parents = True)
|
|
(requirements / "studio.txt").write_text(
|
|
"definitely-missing-studio-package\n",
|
|
encoding = "utf-8",
|
|
)
|
|
run_mod = SimpleNamespace(__file__ = str(backend / "run.py"))
|
|
monkeypatch.setattr(studio, "_load_run_module", lambda: run_mod)
|
|
|
|
with pytest.raises(typer.Exit):
|
|
studio.desktop_runtime_check(_json_output = True)
|
|
|
|
payload = json.loads(capsys.readouterr().out)
|
|
assert payload["module"] == "definitely-missing-studio-package"
|
|
|
|
|
|
def test_desktop_runtime_check_ignores_pip_flag_lines(monkeypatch, capsys, tmp_path):
|
|
"""Repair reinstalls the same file, so an unparseable line breaks it forever."""
|
|
studio = importlib.import_module("unsloth_cli.commands.studio")
|
|
backend = tmp_path / "backend"
|
|
requirements = backend / "requirements"
|
|
requirements.mkdir(parents = True)
|
|
(requirements / "studio.txt").write_text(
|
|
"--extra-index-url https://example.invalid/simple\n-r base.txt\n",
|
|
encoding = "utf-8",
|
|
)
|
|
run_mod = SimpleNamespace(__file__ = str(backend / "run.py"))
|
|
monkeypatch.setattr(studio, "_load_run_module", lambda: run_mod)
|
|
|
|
studio.desktop_runtime_check(_json_output = True)
|
|
|
|
assert json.loads(capsys.readouterr().out) == {"runtime_ready": True}
|
|
|
|
|
|
def test_desktop_runtime_check_accepts_a_prerelease_over_a_floor(monkeypatch, capsys, tmp_path):
|
|
studio = importlib.import_module("unsloth_cli.commands.studio")
|
|
backend = tmp_path / "backend"
|
|
requirements = backend / "requirements"
|
|
requirements.mkdir(parents = True)
|
|
(requirements / "studio.txt").write_text("example-package>=1.0\n", encoding = "utf-8")
|
|
run_mod = SimpleNamespace(__file__ = str(backend / "run.py"))
|
|
monkeypatch.setattr(studio, "_load_run_module", lambda: run_mod)
|
|
monkeypatch.setattr(
|
|
importlib.import_module("importlib.metadata"),
|
|
"distribution",
|
|
lambda _name: SimpleNamespace(
|
|
version = "2.0.0b1",
|
|
files = [],
|
|
requires = None,
|
|
read_text = lambda _n: None,
|
|
),
|
|
)
|
|
|
|
studio.desktop_runtime_check(_json_output = True)
|
|
|
|
assert json.loads(capsys.readouterr().out) == {"runtime_ready": True}
|
|
|
|
|
|
def test_desktop_runtime_check_rejects_version_mismatch(monkeypatch, capsys, tmp_path):
|
|
studio = importlib.import_module("unsloth_cli.commands.studio")
|
|
backend = tmp_path / "backend"
|
|
requirements = backend / "requirements"
|
|
requirements.mkdir(parents = True)
|
|
(requirements / "studio.txt").write_text("example-package==2.0\n", encoding = "utf-8")
|
|
run_mod = SimpleNamespace(__file__ = str(backend / "run.py"))
|
|
monkeypatch.setattr(studio, "_load_run_module", lambda: run_mod)
|
|
monkeypatch.setattr(
|
|
importlib.import_module("importlib.metadata"),
|
|
"distribution",
|
|
lambda _name: SimpleNamespace(
|
|
version = "1.0",
|
|
files = [],
|
|
requires = None,
|
|
read_text = lambda _n: None,
|
|
),
|
|
)
|
|
|
|
with pytest.raises(typer.Exit):
|
|
studio.desktop_runtime_check(_json_output = True)
|
|
|
|
payload = json.loads(capsys.readouterr().out)
|
|
assert payload["module"] == "example-package"
|
|
|
|
|
|
def test_desktop_runtime_check_rejects_metadata_without_an_unpacked_package(
|
|
monkeypatch, capsys, tmp_path
|
|
):
|
|
"""fastapi's wheel stores METADATA first, so an interrupted unpack leaves a
|
|
readable version behind. RECORD is last, so its absence marks the unpack."""
|
|
studio = importlib.import_module("unsloth_cli.commands.studio")
|
|
backend = tmp_path / "backend"
|
|
requirements = backend / "requirements"
|
|
requirements.mkdir(parents = True)
|
|
(requirements / "studio.txt").write_text("fastapi\n", encoding = "utf-8")
|
|
run_mod = SimpleNamespace(__file__ = str(backend / "run.py"))
|
|
monkeypatch.setattr(studio, "_load_run_module", lambda: run_mod)
|
|
monkeypatch.setattr(
|
|
importlib.import_module("importlib.metadata"),
|
|
"distribution",
|
|
lambda _name: SimpleNamespace(
|
|
version = "0.140.5",
|
|
files = None,
|
|
requires = None,
|
|
read_text = lambda _n: None,
|
|
),
|
|
)
|
|
|
|
with pytest.raises(typer.Exit):
|
|
studio.desktop_runtime_check(_json_output = True)
|
|
|
|
payload = json.loads(capsys.readouterr().out)
|
|
assert payload["reason"] == "missing_dependency"
|
|
assert payload["module"] == "fastapi"
|
|
|
|
|
|
def _fake_distributions(monkeypatch, installed):
|
|
metadata = importlib.import_module("importlib.metadata")
|
|
|
|
def _distribution(name):
|
|
try:
|
|
version, requires = installed[name]
|
|
except KeyError:
|
|
raise metadata.PackageNotFoundError(name) from None
|
|
return SimpleNamespace(
|
|
version = version,
|
|
files = [],
|
|
requires = requires,
|
|
read_text = lambda _n: None,
|
|
)
|
|
|
|
monkeypatch.setattr(metadata, "distribution", _distribution)
|
|
|
|
|
|
def test_desktop_runtime_check_rejects_a_missing_transitive_dependency(
|
|
monkeypatch, capsys, tmp_path
|
|
):
|
|
"""starlette reaches the venv only as a FastAPI dependency, so a direct-only
|
|
check calls the install ready and the server dies on start."""
|
|
studio = importlib.import_module("unsloth_cli.commands.studio")
|
|
backend = tmp_path / "backend"
|
|
requirements = backend / "requirements"
|
|
requirements.mkdir(parents = True)
|
|
(requirements / "studio.txt").write_text("fastapi>=0.115\n", encoding = "utf-8")
|
|
run_mod = SimpleNamespace(__file__ = str(backend / "run.py"))
|
|
monkeypatch.setattr(studio, "_load_run_module", lambda: run_mod)
|
|
_fake_distributions(monkeypatch, {"fastapi": ("0.140.5", ["starlette>=0.40"])})
|
|
|
|
with pytest.raises(typer.Exit):
|
|
studio.desktop_runtime_check(_json_output = True)
|
|
|
|
assert json.loads(capsys.readouterr().out)["module"] == "starlette"
|
|
|
|
|
|
def test_desktop_runtime_check_ignores_optional_and_circular_dependencies(
|
|
monkeypatch, capsys, tmp_path
|
|
):
|
|
"""Extras-only dependencies are not missing, and a cycle must terminate."""
|
|
studio = importlib.import_module("unsloth_cli.commands.studio")
|
|
backend = tmp_path / "backend"
|
|
requirements = backend / "requirements"
|
|
requirements.mkdir(parents = True)
|
|
(requirements / "studio.txt").write_text("fastapi\n", encoding = "utf-8")
|
|
run_mod = SimpleNamespace(__file__ = str(backend / "run.py"))
|
|
monkeypatch.setattr(studio, "_load_run_module", lambda: run_mod)
|
|
_fake_distributions(
|
|
monkeypatch,
|
|
{
|
|
"fastapi": ("0.140.5", ['uvicorn; extra == "standard"', "starlette"]),
|
|
# Transitive bounds are not enforced: --no-deps installs leave unmet
|
|
# ones on venvs that work.
|
|
"starlette": ("0.1", ["fastapi>=99"]),
|
|
},
|
|
)
|
|
|
|
studio.desktop_runtime_check(_json_output = True)
|
|
|
|
assert json.loads(capsys.readouterr().out) == {"runtime_ready": True}
|
|
|
|
|
|
def test_desktop_runtime_check_reports_a_rejected_setting_instead_of_exiting(monkeypatch, capsys):
|
|
"""run.py raises SystemExit for values like UNSLOTH_CPU_THREADS=invalid, and
|
|
with no payload the app reinstalls over a value no install can change."""
|
|
studio = importlib.import_module("unsloth_cli.commands.studio")
|
|
|
|
def _rejected_setting():
|
|
raise SystemExit("Error: Invalid UNSLOTH_CPU_THREADS value 'invalid'")
|
|
|
|
monkeypatch.setattr(studio, "_load_run_module", _rejected_setting)
|
|
|
|
with pytest.raises(typer.Exit) as exited:
|
|
studio.desktop_runtime_check(_json_output = True)
|
|
|
|
assert exited.value.exit_code == 1
|
|
payload = json.loads(capsys.readouterr().out)
|
|
assert payload["runtime_ready"] is False
|
|
assert payload["reason"] == "backend_startup_failed"
|
|
assert "UNSLOTH_CPU_THREADS" in payload["error"]
|
|
|
|
|
|
def test_a_root_pin_is_checked_even_when_a_dependency_names_it_first(monkeypatch, capsys, tmp_path):
|
|
"""datasets wants huggingface-hub<2, studio.txt pins ==0.36.2. Reached as a
|
|
dependency first, the pin would never get to decide."""
|
|
studio = importlib.import_module("unsloth_cli.commands.studio")
|
|
backend = tmp_path / "backend"
|
|
requirements = backend / "requirements"
|
|
requirements.mkdir(parents = True)
|
|
(requirements / "studio.txt").write_text(
|
|
"datasets==4.3.0\nhuggingface-hub==0.36.2\n",
|
|
encoding = "utf-8",
|
|
)
|
|
run_mod = SimpleNamespace(__file__ = str(backend / "run.py"))
|
|
monkeypatch.setattr(studio, "_load_run_module", lambda: run_mod)
|
|
_fake_distributions(
|
|
monkeypatch,
|
|
{
|
|
"datasets": ("4.3.0", ["huggingface-hub>=0.25,<2"]),
|
|
"huggingface-hub": ("1.25.1", None),
|
|
},
|
|
)
|
|
|
|
with pytest.raises(typer.Exit):
|
|
studio.desktop_runtime_check(_json_output = True)
|
|
|
|
assert json.loads(capsys.readouterr().out)["module"] == "huggingface-hub"
|
|
|
|
|
|
def test_a_record_without_its_files_is_reported_missing(monkeypatch, capsys, tmp_path):
|
|
"""An interrupted replace can recreate the package directory and stop
|
|
part-way through filling it, so the directory existing proves nothing.
|
|
|
|
Built as a real dist-info rather than a stub: Distribution.files drops
|
|
entries that no longer exist, which is the whole set this looks for."""
|
|
studio = importlib.import_module("unsloth_cli.commands.studio")
|
|
metadata = importlib.import_module("importlib.metadata")
|
|
backend = tmp_path / "backend"
|
|
requirements = backend / "requirements"
|
|
requirements.mkdir(parents = True)
|
|
(requirements / "studio.txt").write_text("structlog\n", encoding = "utf-8")
|
|
run_mod = SimpleNamespace(__file__ = str(backend / "run.py"))
|
|
monkeypatch.setattr(studio, "_load_run_module", lambda: run_mod)
|
|
|
|
site_packages = tmp_path / "site-packages"
|
|
dist_info = site_packages / "structlog-25.1.0.dist-info"
|
|
dist_info.mkdir(parents = True)
|
|
(dist_info / "METADATA").write_text(
|
|
"Metadata-Version: 2.1\nName: structlog\nVersion: 25.1.0\n",
|
|
encoding = "utf-8",
|
|
)
|
|
(dist_info / "RECORD").write_text(
|
|
"structlog/__init__.py,,\n"
|
|
"structlog/processors.py,,\n"
|
|
"structlog/__pycache__/__init__.cpython-313.pyc,,\n"
|
|
"structlog-25.1.0.dist-info/RECORD,,\n",
|
|
encoding = "utf-8",
|
|
)
|
|
installed = next(iter(metadata.distributions(path = [str(site_packages)])))
|
|
monkeypatch.setattr(metadata, "distribution", lambda _name: installed)
|
|
|
|
with pytest.raises(typer.Exit):
|
|
studio.desktop_runtime_check(_json_output = True)
|
|
assert json.loads(capsys.readouterr().out)["module"] == "structlog"
|
|
|
|
# The directory back but still a file short is the interrupted replace.
|
|
(site_packages / "structlog").mkdir()
|
|
(site_packages / "structlog" / "__init__.py").touch()
|
|
with pytest.raises(typer.Exit):
|
|
studio.desktop_runtime_check(_json_output = True)
|
|
assert json.loads(capsys.readouterr().out)["module"] == "structlog"
|
|
|
|
# Complete, and the never-written .pyc must not count as damage.
|
|
(site_packages / "structlog" / "processors.py").touch()
|
|
studio.desktop_runtime_check(_json_output = True)
|
|
assert json.loads(capsys.readouterr().out) == {"runtime_ready": True}
|