Files
unslothai--unsloth/studio/backend/tests/test_training_preflight.py
T
wehub-resource-sync e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

421 lines
14 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
"""_preflight_first_batch rejects an empty/non-integer first batch (the base-model
empty-chat-template crash) before train(). The real methods are bound onto a light
fake self so the production logic runs against controlled batches."""
import importlib
import json
import os
import queue
import subprocess
import sys
import threading
import types
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import torch
def _stub_if_missing(name, attrs):
"""Register a stub module for a dep the CPU backend CI job does not install.
The pytest job has studio.txt + torch + transformers but not unsloth/trl,
which core.training.trainer imports at module scope. Stub the absent ones
(real installs are left alone) so importing it for the two pure helper
methods never breaks test collection. __spec__ = None keeps the trainer's
own _ensure_real_packages namespace-shadow guard a no-op on the stub.
"""
if name in sys.modules:
return
try:
importlib.import_module(name)
return
except Exception:
pass
mod = types.ModuleType(name)
mod.__spec__ = None
for attr in attrs:
setattr(mod, attr, MagicMock())
sys.modules[name] = mod
parent, _, child = name.rpartition(".")
if parent and parent in sys.modules:
setattr(sys.modules[parent], child, mod)
_stub_if_missing("unsloth", ("FastLanguageModel", "FastVisionModel", "is_bfloat16_supported"))
_stub_if_missing("unsloth.chat_templates", ("get_chat_template",))
_stub_if_missing("trl", ("SFTTrainer", "SFTConfig"))
from core.training.trainer import UnslothTrainer # noqa: E402
_preflight = UnslothTrainer._preflight_first_batch
_renders_empty = UnslothTrainer._chat_template_renders_empty
class _FakeInnerTrainer:
def __init__(
self,
*,
batch = None,
dataloader_error = None,
train_dataset = None,
):
self._batch = batch
self._dataloader_error = dataloader_error
self.train_dataset = train_dataset
def get_train_dataloader(self):
if self._dataloader_error is not None:
raise self._dataloader_error
return [self._batch]
def _fake_self(
*,
inner,
model_name = "org/Some-Model",
tokenizer = None,
):
s = SimpleNamespace(trainer = inner, model_name = model_name, tokenizer = tokenizer)
# Bind real methods so self._chat_template_renders_empty() resolves.
s._preflight_first_batch = _preflight.__get__(s)
s._chat_template_renders_empty = _renders_empty.__get__(s)
return s
class _EmptyTemplateTokenizer:
def apply_chat_template(
self,
messages,
tokenize = False,
add_generation_prompt = False,
):
return ""
class _RealTemplateTokenizer:
def apply_chat_template(
self,
messages,
tokenize = False,
add_generation_prompt = False,
):
return "<|im_start|>user\nhi<|im_end|>"
class TestPreflightFirstBatch(unittest.TestCase):
def test_float_input_ids_with_empty_template_suggests_instruct(self):
ds = [{"messages": [{"role": "user", "content": [{"type": "text", "text": "x"}]}]}]
inner = _FakeInnerTrainer(
batch = {"input_ids": torch.zeros((1, 0), dtype = torch.float32)},
train_dataset = ds,
)
s = _fake_self(
inner = inner, model_name = "Qwen/Qwen2-VL-7B", tokenizer = _EmptyTemplateTokenizer()
)
msg = s._preflight_first_batch()
self.assertIsNotNone(msg)
self.assertIn("chat template", msg)
self.assertIn("Qwen/Qwen2-VL-7B-Instruct", msg)
self.assertIn("base (pretrained) model", msg)
def test_no_instruct_hint_when_model_already_instruct(self):
ds = [{"messages": [{"role": "user", "content": [{"type": "text", "text": "x"}]}]}]
inner = _FakeInnerTrainer(
batch = {"input_ids": torch.zeros((1, 0), dtype = torch.float32)},
train_dataset = ds,
)
s = _fake_self(
inner = inner, model_name = "org/Foo-Instruct", tokenizer = _EmptyTemplateTokenizer()
)
msg = s._preflight_first_batch()
self.assertIsNotNone(msg)
self.assertNotIn("such as", msg) # no Instruct suggestion for an Instruct model
self.assertIn("instruction-tuned variant", msg)
def test_empty_int_input_ids_generic_message(self):
inner = _FakeInnerTrainer(
batch = {"input_ids": torch.zeros((1, 0), dtype = torch.long)},
train_dataset = [{"text": "already tokenized path"}],
)
s = _fake_self(inner = inner, tokenizer = _RealTemplateTokenizer())
msg = s._preflight_first_batch()
self.assertIsNotNone(msg)
self.assertIn("invalid token IDs", msg)
self.assertNotIn("chat template", msg)
def test_valid_batch_returns_none(self):
inner = _FakeInnerTrainer(
batch = {"input_ids": torch.randint(0, 1000, (2, 34), dtype = torch.long)},
)
s = _fake_self(inner = inner)
self.assertIsNone(s._preflight_first_batch())
def test_dataloader_error_is_surfaced(self):
inner = _FakeInnerTrainer(dataloader_error = RuntimeError("boom"))
s = _fake_self(inner = inner, model_name = "org/M")
msg = s._preflight_first_batch()
self.assertIsNotNone(msg)
self.assertIn("failed to build the first training batch", msg)
self.assertIn("org/M", msg)
def test_missing_input_ids_does_not_false_positive(self):
inner = _FakeInnerTrainer(batch = {"pixel_values": torch.zeros((1, 3))})
s = _fake_self(inner = inner)
self.assertIsNone(s._preflight_first_batch())
class TestChatTemplateRendersEmpty(unittest.TestCase):
def _self(self, *, train_dataset, tokenizer):
inner = _FakeInnerTrainer(train_dataset = train_dataset)
return _fake_self(inner = inner, tokenizer = tokenizer)
def test_empty_render_detected(self):
ds = [{"messages": [{"role": "user", "content": [{"type": "text", "text": "x"}]}]}]
s = self._self(train_dataset = ds, tokenizer = _EmptyTemplateTokenizer())
self.assertTrue(s._chat_template_renders_empty())
def test_nonempty_render_not_flagged(self):
ds = [{"messages": [{"role": "user", "content": [{"type": "text", "text": "x"}]}]}]
s = self._self(train_dataset = ds, tokenizer = _RealTemplateTokenizer())
self.assertFalse(s._chat_template_renders_empty())
def test_no_messages_key_not_flagged(self):
s = self._self(train_dataset = [{"text": "raw"}], tokenizer = _EmptyTemplateTokenizer())
self.assertFalse(s._chat_template_renders_empty())
def _clear_trainer_module(package: str):
sys.modules.pop(f"{package}.trainer", None)
pkg = sys.modules.get(package)
if pkg is not None and hasattr(pkg, "trainer"):
delattr(pkg, "trainer")
def _set_training_platform(monkeypatch, package: str, backend: str):
training_mod = importlib.import_module(f"{package}.training")
from utils.hardware import hardware as hw
monkeypatch.setattr(hw, "DEVICE", None)
monkeypatch.setattr(
training_mod.platform,
"system",
lambda: "Darwin" if backend == "mlx" else "Linux",
)
monkeypatch.setattr(
training_mod.platform,
"machine",
lambda: "arm64" if backend == "mlx" else "x86_64",
)
def _load_trainer_module(
monkeypatch,
backend: str,
package: str = "core.training",
):
_set_training_platform(monkeypatch, package, backend)
_clear_trainer_module(package)
if package in sys.modules:
importlib.reload(sys.modules[package])
trainer_mod = importlib.import_module(f"{package}.trainer")
training_mod = importlib.import_module(f"{package}.training")
monkeypatch.setattr(
training_mod._MLXTrainerAdapter,
"_activate_transformers_for_model",
lambda self, model_name, hf_token: None,
)
return trainer_mod
class _ExitedProc:
def join(self, timeout = None):
return None
def is_alive(self):
return False
class _TerminableProc:
def __init__(self):
self.terminated = False
self._done = threading.Event()
def join(self, timeout = None):
self._done.wait(timeout = timeout or 5)
def is_alive(self):
return not self.terminated
def terminate(self):
self.terminated = True
self._done.set()
def test_unsloth_trainer_dispatches_for_mlx_and_torch(monkeypatch):
trainer_mod = _load_trainer_module(monkeypatch, "mlx")
mlx_trainer = trainer_mod.UnslothTrainer()
assert type(mlx_trainer).__module__ == "core.training.training"
assert mlx_trainer.get_training_progress().status_message == "Ready to train"
trainer_mod = _load_trainer_module(monkeypatch, "torch")
assert trainer_mod.UnslothTrainer().__class__ is trainer_mod.UnslothTrainer
def test_cli_mlx_trainer_activates_before_importing_trainer():
repo_root = Path(__file__).resolve().parents[3]
script = """
import json
import sys
import unsloth_cli.commands.train as train_cmd
from studio.backend.core.training import training as training_mod
from utils.hardware import hardware as hw
training_mod.platform.system = lambda: "Darwin"
training_mod.platform.machine = lambda: "arm64"
hw.DEVICE = None
events = []
def fake_activate(model_name, hf_token):
events.append({
"model_name": model_name,
"trainer_loaded": "studio.backend.core.training.trainer" in sys.modules,
})
train_cmd._activate_mlx_transformers = fake_activate
trainer = train_cmd._create_cli_trainer("mlx-community/Qwen3-0.6B-4bit", None)
print(json.dumps({
"trainer_module": type(trainer).__module__,
"events": events,
}))
"""
env = os.environ.copy()
env["PYTHONPATH"] = os.pathsep.join(
[str(repo_root), str(repo_root / "studio" / "backend"), env.get("PYTHONPATH", "")]
)
result = subprocess.run(
[sys.executable, "-c", script],
cwd = repo_root,
env = env,
text = True,
stdout = subprocess.PIPE,
stderr = subprocess.PIPE,
check = True,
)
payload = json.loads(result.stdout)
assert payload["trainer_module"] == "studio.backend.core.training.training"
assert payload["events"] == [
{"model_name": "mlx-community/Qwen3-0.6B-4bit", "trainer_loaded": False}
]
def test_mlx_adapter_builds_config_and_reports_completion(tmp_path, monkeypatch):
trainer_mod = _load_trainer_module(monkeypatch, "mlx")
captured = {}
def fake_run_worker(config, event_queue, stop_queue):
captured["config"] = config
event_queue.put({"type": "progress", "step": 1, "total_steps": 1, "loss": 0.25})
event_queue.put(
{"type": "complete", "status_message": "done", "output_dir": config["output_dir"]}
)
trainer = trainer_mod.UnslothTrainer()
monkeypatch.setattr(trainer, "_run_mlx_worker", fake_run_worker)
assert trainer.load_model("mlx-community/Qwen3-0.6B-4bit", max_seq_length = 1024)
assert trainer.prepare_model_for_training(use_lora = False)
dataset, eval_dataset = trainer.load_and_format_dataset("org/dataset")
output_dir = tmp_path / "mlx-out"
assert trainer.start_training(
dataset = dataset,
eval_dataset = eval_dataset,
output_dir = output_dir,
project_name = "Sales Assistant",
max_steps = 1,
learning_rate = 3e-4,
)
trainer.training_thread.join(timeout = 5)
progress = trainer.get_training_progress()
config = captured["config"]
assert progress.is_completed
assert progress.output_dir == str(output_dir.resolve())
progress.status_message = "mutated"
assert trainer.get_training_progress().status_message == "done"
assert config["model_name"] == "mlx-community/Qwen3-0.6B-4bit"
assert config["project_name"] == "Sales Assistant"
assert config["hf_dataset"] == "org/dataset"
assert config["training_type"] == "Full Finetuning"
assert config["load_in_4bit"] is False
assert config["max_seq_length"] == 1024
assert config["learning_rate"] == 3e-4
assert config["output_dir"] == str(output_dir.resolve())
assert config["allow_external_output_dir"] is True
def test_mlx_worker_helpers_cover_cli_paths(tmp_path, monkeypatch):
_load_trainer_module(monkeypatch, "mlx")
from core.training.worker import (
_resolve_mlx_local_dataset_files,
_resolve_mlx_output_dir,
)
dataset = tmp_path / "train.jsonl"
dataset.write_text('{"text":"hello"}\n', encoding = "utf-8")
monkeypatch.chdir(tmp_path)
assert _resolve_mlx_local_dataset_files(["train.jsonl"]) == [str(dataset)]
assert _resolve_mlx_output_dir(
{"output_dir": "cli-out", "allow_external_output_dir": True},
"mlx-community/Qwen3-0.6B-4bit",
) == str((tmp_path / "cli-out").resolve())
def test_run_mlx_training_process_applies_side_effects_before_hardware_detection(monkeypatch):
_load_trainer_module(monkeypatch, "mlx")
from core.training import worker
from utils.hardware import hardware as hw
order = []
def fake_activate(model_name, hf_token):
order.append(("activate", model_name, hf_token))
def fake_detect_hardware():
order.append("detect")
hw.DEVICE = hw.DeviceType.CPU
return hw.DEVICE
monkeypatch.delenv("HF_HUB_DISABLE_XET", raising = False)
monkeypatch.delenv("HF_HUB_ENABLE_HF_TRANSFER", raising = False)
monkeypatch.setattr(worker, "_activate_transformers_version_or_warn", fake_activate)
monkeypatch.setattr(hw, "detect_hardware", fake_detect_hardware)
event_queue = queue.Queue()
worker.run_mlx_training_process(
event_queue = event_queue,
stop_queue = queue.Queue(),
config = {"model_name": "mlx-community/Gemma-4-12B", "disable_xet": True},
)
event = event_queue.get_nowait()
assert order == [("activate", "mlx-community/Gemma-4-12B", None), "detect"]
assert os.environ["HF_HUB_DISABLE_XET"] == "1"
assert os.environ["HF_HUB_ENABLE_HF_TRANSFER"] == "0"
assert "MLX training requires Apple Silicon" in event["error"]
if __name__ == "__main__":
unittest.main()