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
571 lines
19 KiB
Python
571 lines
19 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
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import importlib.util
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from pydantic import ValidationError
|
|
|
|
from models.training import TrainingStartRequest
|
|
from utils.datasets.chat_templates import apply_chat_template_to_dataset
|
|
from utils.datasets.format_conversion import convert_chatml_to_alpaca
|
|
from utils.datasets.iterable import is_streaming_dataset
|
|
|
|
datasets = pytest.importorskip("datasets")
|
|
|
|
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
def _load_route_module(name: str, relative_path: str):
|
|
spec = importlib.util.spec_from_file_location(name, _BACKEND_ROOT / relative_path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
class _Tokenizer:
|
|
eos_token = "</s>"
|
|
chat_template = "{{ messages }}"
|
|
|
|
def apply_chat_template(
|
|
self,
|
|
conversation,
|
|
*,
|
|
tokenize = False,
|
|
add_generation_prompt = False,
|
|
):
|
|
assert tokenize is False
|
|
assert add_generation_prompt is False
|
|
return "\n".join(f"{message['role']}: {message['content']}" for message in conversation)
|
|
|
|
|
|
def _iterable_dataset(rows):
|
|
return datasets.IterableDataset.from_generator(lambda: iter(rows))
|
|
|
|
|
|
# --- Streaming keeps dataset.map() lazy: eager-only kwargs (num_proc/desc) are
|
|
# omitted for IterableDatasets, which reject them. One per module. ---
|
|
|
|
|
|
def test_chat_template_mapping_omits_eager_kwargs_for_streaming(monkeypatch):
|
|
seen_kwargs = []
|
|
original_map = datasets.IterableDataset.map
|
|
|
|
def spy_map(self, *args, **kwargs):
|
|
seen_kwargs.append(dict(kwargs))
|
|
return original_map(self, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(datasets.IterableDataset, "map", spy_map)
|
|
|
|
dataset = _iterable_dataset(
|
|
[
|
|
{
|
|
"conversations": [
|
|
{"role": "user", "content": "Hi"},
|
|
{"role": "assistant", "content": "Hello"},
|
|
]
|
|
}
|
|
]
|
|
)
|
|
result = apply_chat_template_to_dataset(
|
|
{
|
|
"dataset": dataset,
|
|
"final_format": "chatml_conversations",
|
|
"chat_column": "conversations",
|
|
"is_standardized": True,
|
|
},
|
|
tokenizer = _Tokenizer(),
|
|
batch_size = 1,
|
|
num_proc = 2,
|
|
)
|
|
|
|
assert result["success"] is True
|
|
row = next(iter(result["dataset"]))
|
|
assert "user: Hi" in row["text"]
|
|
assert seen_kwargs
|
|
assert all("num_proc" not in kwargs for kwargs in seen_kwargs)
|
|
assert all("desc" not in kwargs for kwargs in seen_kwargs)
|
|
|
|
|
|
def test_format_conversion_omits_eager_kwargs_for_streaming(monkeypatch):
|
|
seen_kwargs = []
|
|
original_map = datasets.IterableDataset.map
|
|
|
|
def spy_map(self, *args, **kwargs):
|
|
seen_kwargs.append(dict(kwargs))
|
|
return original_map(self, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(datasets.IterableDataset, "map", spy_map)
|
|
|
|
converted = convert_chatml_to_alpaca(
|
|
_iterable_dataset(
|
|
[
|
|
{
|
|
"conversations": [
|
|
{"from": "human", "value": "Question"},
|
|
{"from": "gpt", "value": "Answer"},
|
|
]
|
|
}
|
|
]
|
|
),
|
|
batch_size = 1,
|
|
num_proc = 2,
|
|
)
|
|
|
|
row = next(iter(converted))
|
|
assert row["instruction"] == "Question"
|
|
assert row["output"] == "Answer"
|
|
assert seen_kwargs
|
|
assert all("num_proc" not in kwargs for kwargs in seen_kwargs)
|
|
assert all("desc" not in kwargs for kwargs in seen_kwargs)
|
|
|
|
|
|
# --- Streaming detection ---
|
|
|
|
|
|
def test_is_streaming_dataset_detects_hf_iterable():
|
|
assert is_streaming_dataset(_iterable_dataset([{"a": 1}])) is True
|
|
|
|
|
|
def test_is_streaming_dataset_false_for_plain_list():
|
|
assert is_streaming_dataset([{"a": 1}]) is False
|
|
|
|
|
|
# --- Raw-text / CPT streaming: keep the lazy filter, skip the len()-based
|
|
# counting that would TypeError on an IterableDataset (the BLOCKER fix). ---
|
|
|
|
|
|
def test_drop_invalid_text_rows_streaming_keeps_filter_skips_len():
|
|
from utils.datasets.raw_text import _drop_invalid_text_rows
|
|
|
|
stream = datasets.Dataset.from_list(
|
|
[{"text": "keep1"}, {"text": None}, {"text": "keep2"}]
|
|
).to_iterable_dataset()
|
|
assert not hasattr(stream, "__len__")
|
|
|
|
filtered, notices = _drop_invalid_text_rows(
|
|
stream, mode_title = "Raw text", split_scope = "this dataset"
|
|
)
|
|
|
|
# Result still streams; only string-'text' rows survive.
|
|
assert [row["text"] for row in filtered] == ["keep1", "keep2"]
|
|
assert any(n.level == "info" for n in notices)
|
|
|
|
|
|
# --- Request validation ---
|
|
|
|
|
|
def test_dataset_slice_bounds_are_non_negative():
|
|
with pytest.raises(ValidationError):
|
|
TrainingStartRequest(
|
|
model_name = "unsloth/test",
|
|
training_type = "LoRA/QLoRA",
|
|
format_type = "alpaca",
|
|
dataset_slice_start = -1,
|
|
)
|
|
|
|
with pytest.raises(ValidationError):
|
|
TrainingStartRequest(
|
|
model_name = "unsloth/test",
|
|
training_type = "LoRA/QLoRA",
|
|
format_type = "alpaca",
|
|
dataset_slice_start = 5,
|
|
dataset_slice_end = 4,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad_hf_dataset",
|
|
["../../etc/passwd", "org/../../secret", "a" * 257],
|
|
)
|
|
def test_hf_dataset_rejects_unsafe_values(bad_hf_dataset):
|
|
with pytest.raises(ValidationError):
|
|
TrainingStartRequest(
|
|
model_name = "unsloth/test",
|
|
training_type = "LoRA/QLoRA",
|
|
format_type = "alpaca",
|
|
hf_dataset = bad_hf_dataset,
|
|
)
|
|
|
|
|
|
def test_project_name_rejects_values_over_ui_limit():
|
|
with pytest.raises(ValidationError):
|
|
TrainingStartRequest(
|
|
model_name = "unsloth/test",
|
|
project_name = "x" * 81,
|
|
training_type = "LoRA/QLoRA",
|
|
format_type = "alpaca",
|
|
)
|
|
|
|
|
|
# --- Start-route streaming compatibility guards ---
|
|
|
|
|
|
def test_streaming_start_rejects_train_on_completions_before_backend_start():
|
|
training_route = _load_route_module(
|
|
"training_route_module_for_streaming_completion_test",
|
|
"routes/training.py",
|
|
)
|
|
request = TrainingStartRequest(
|
|
model_name = "unsloth/test",
|
|
training_type = "LoRA/QLoRA",
|
|
hf_dataset = "org/dataset",
|
|
format_type = "chatml",
|
|
dataset_streaming = True,
|
|
train_on_completions = True,
|
|
max_steps = 10,
|
|
)
|
|
|
|
backend = SimpleNamespace(
|
|
current_job_id = None,
|
|
is_training_active = lambda: False,
|
|
start_training = lambda **kwargs: pytest.fail("backend should not start"),
|
|
)
|
|
|
|
with patch.object(training_route, "get_training_backend", return_value = backend):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(training_route.start_training(request, current_subject = "test-user"))
|
|
|
|
assert exc_info.value.status_code == 422
|
|
assert "train_on_completions" in exc_info.value.detail
|
|
|
|
|
|
@pytest.mark.parametrize("eval_split", [None, "train"])
|
|
def test_streaming_start_requires_separate_eval_split(eval_split):
|
|
training_route = _load_route_module(
|
|
"training_route_module_for_streaming_eval_test",
|
|
"routes/training.py",
|
|
)
|
|
request = TrainingStartRequest(
|
|
model_name = "unsloth/test",
|
|
training_type = "LoRA/QLoRA",
|
|
hf_dataset = "org/dataset",
|
|
format_type = "chatml",
|
|
dataset_streaming = True,
|
|
train_split = "train",
|
|
eval_split = eval_split,
|
|
eval_steps = 0.1,
|
|
max_steps = 10,
|
|
)
|
|
|
|
backend = SimpleNamespace(
|
|
current_job_id = None,
|
|
is_training_active = lambda: False,
|
|
start_training = lambda **kwargs: pytest.fail("backend should not start"),
|
|
)
|
|
|
|
with patch.object(training_route, "get_training_backend", return_value = backend):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(training_route.start_training(request, current_subject = "test-user"))
|
|
|
|
assert exc_info.value.status_code == 422
|
|
assert "separate eval_split" in exc_info.value.detail
|
|
|
|
|
|
def test_streaming_start_rejects_missing_max_steps():
|
|
training_route = _load_route_module(
|
|
"training_route_module_for_streaming_max_steps_test",
|
|
"routes/training.py",
|
|
)
|
|
request = TrainingStartRequest(
|
|
model_name = "unsloth/test",
|
|
training_type = "LoRA/QLoRA",
|
|
hf_dataset = "org/dataset",
|
|
format_type = "chatml",
|
|
dataset_streaming = True,
|
|
max_steps = 0,
|
|
)
|
|
|
|
backend = SimpleNamespace(
|
|
current_job_id = None,
|
|
is_training_active = lambda: False,
|
|
start_training = lambda **kwargs: pytest.fail("backend should not start"),
|
|
)
|
|
|
|
with patch.object(training_route, "get_training_backend", return_value = backend):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(training_route.start_training(request, current_subject = "test-user"))
|
|
|
|
assert exc_info.value.status_code == 422
|
|
assert "max_steps" in exc_info.value.detail
|
|
|
|
|
|
def test_streaming_start_rejects_embedding_models():
|
|
# The embedding training path loads the full dataset (no streaming) and uses
|
|
# len/select, so the route must reject streaming for embedding runs even on a
|
|
# direct API call (the UI blocker doesn't cover that).
|
|
training_route = _load_route_module(
|
|
"training_route_module_for_streaming_embedding_test",
|
|
"routes/training.py",
|
|
)
|
|
request = TrainingStartRequest(
|
|
model_name = "unsloth/test",
|
|
training_type = "LoRA/QLoRA",
|
|
hf_dataset = "org/dataset",
|
|
format_type = "chatml",
|
|
dataset_streaming = True,
|
|
is_embedding = True,
|
|
max_steps = 10,
|
|
)
|
|
|
|
backend = SimpleNamespace(
|
|
current_job_id = None,
|
|
is_training_active = lambda: False,
|
|
start_training = lambda **kwargs: pytest.fail("backend should not start"),
|
|
)
|
|
|
|
with patch.object(training_route, "get_training_backend", return_value = backend):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(training_route.start_training(request, current_subject = "test-user"))
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert "embedding" in exc_info.value.detail
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"training_type, format_type",
|
|
[
|
|
("LoRA/QLoRA", "raw"), # raw-text format
|
|
("Continued Pretraining", "chatml"), # CPT
|
|
],
|
|
)
|
|
def test_streaming_start_accepts_raw_text_and_cpt(training_type, format_type):
|
|
# Streaming + raw-text / CPT is supported: _drop_invalid_text_rows skips its
|
|
# len()-based checks for IterableDatasets, so the start route must NOT reject.
|
|
training_route = _load_route_module(
|
|
"training_route_module_for_streaming_raw_cpt_accept_test",
|
|
"routes/training.py",
|
|
)
|
|
request = TrainingStartRequest(
|
|
model_name = "unsloth/test",
|
|
training_type = training_type,
|
|
hf_dataset = "org/dataset",
|
|
format_type = format_type,
|
|
dataset_streaming = True,
|
|
max_steps = 10,
|
|
)
|
|
|
|
captured = {}
|
|
|
|
def _start_training(**kwargs):
|
|
captured.update(kwargs)
|
|
return True
|
|
|
|
backend = SimpleNamespace(
|
|
current_job_id = "job_test",
|
|
is_training_active = lambda: False,
|
|
start_training = _start_training,
|
|
)
|
|
|
|
with patch.object(training_route, "get_training_backend", return_value = backend):
|
|
with patch.object(training_route, "load_model_defaults", return_value = {}):
|
|
response = asyncio.run(
|
|
training_route.start_training(request, current_subject = "test-user")
|
|
)
|
|
|
|
assert response.status == "queued"
|
|
assert captured["dataset_streaming"] is True
|
|
assert captured["format_type"] == format_type
|
|
|
|
|
|
def test_streaming_start_happy_path_reaches_backend():
|
|
training_route = _load_route_module(
|
|
"training_route_module_for_streaming_happy_path_test",
|
|
"routes/training.py",
|
|
)
|
|
request = TrainingStartRequest(
|
|
model_name = "unsloth/test",
|
|
training_type = "LoRA/QLoRA",
|
|
hf_dataset = "org/dataset",
|
|
format_type = "chatml",
|
|
dataset_streaming = True,
|
|
train_split = "train",
|
|
eval_split = "validation",
|
|
eval_steps = 0.1,
|
|
max_steps = 10,
|
|
)
|
|
|
|
captured = {}
|
|
|
|
def _start_training(**kwargs):
|
|
captured.update(kwargs)
|
|
return True
|
|
|
|
backend = SimpleNamespace(
|
|
current_job_id = "job_test",
|
|
is_training_active = lambda: False,
|
|
start_training = _start_training,
|
|
)
|
|
|
|
with patch.object(training_route, "get_training_backend", return_value = backend):
|
|
with patch.object(training_route, "load_model_defaults", return_value = {}):
|
|
response = asyncio.run(
|
|
training_route.start_training(request, current_subject = "test-user")
|
|
)
|
|
|
|
assert response.status == "queued"
|
|
assert captured["dataset_streaming"] is True
|
|
assert captured["max_steps"] == 10
|
|
assert captured["eval_split"] == "validation"
|
|
|
|
|
|
# streaming rejects HF slice syntax in train_split / eval_split
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field, value",
|
|
[
|
|
("train_split", "train[:50%]"),
|
|
("train_split", "train[:20]"),
|
|
("eval_split", "validation[:1000]"),
|
|
],
|
|
)
|
|
def test_streaming_rejects_bracketed_split_syntax(field, value):
|
|
# The model_validator _validate_streaming_splits raises ValidationError when
|
|
# dataset_streaming=True and a split contains "[" (HF slice syntax).
|
|
kwargs = {
|
|
"model_name": "unsloth/test",
|
|
"training_type": "LoRA/QLoRA",
|
|
"hf_dataset": "org/dataset",
|
|
"format_type": "chatml",
|
|
"dataset_streaming": True,
|
|
"max_steps": 10,
|
|
field: value,
|
|
}
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
TrainingStartRequest(**kwargs)
|
|
detail = str(exc_info.value)
|
|
assert "slice" in detail.lower() or "bracket" in detail.lower() or "[" in detail
|
|
|
|
|
|
# streaming rejects mixed sources (local_datasets)
|
|
|
|
|
|
def test_streaming_start_rejects_local_datasets():
|
|
# dataset_streaming + local_datasets -> 400, 'local' in detail
|
|
training_route = _load_route_module(
|
|
"training_route_module_for_streaming_local_datasets_test",
|
|
"routes/training.py",
|
|
)
|
|
request = TrainingStartRequest(
|
|
model_name = "unsloth/test",
|
|
training_type = "LoRA/QLoRA",
|
|
hf_dataset = "org/dataset",
|
|
format_type = "chatml",
|
|
dataset_streaming = True,
|
|
max_steps = 10,
|
|
)
|
|
# Bypass Pydantic's local-path validation by injecting directly after construction.
|
|
object.__setattr__(request, "local_datasets", ["/some/local/file.jsonl"])
|
|
|
|
backend = SimpleNamespace(
|
|
current_job_id = None,
|
|
is_training_active = lambda: False,
|
|
start_training = lambda **kwargs: pytest.fail("backend should not start"),
|
|
)
|
|
|
|
with patch.object(training_route, "get_training_backend", return_value = backend):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(training_route.start_training(request, current_subject = "test-user"))
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert "local" in exc_info.value.detail.lower() or "hf-only" in exc_info.value.detail.lower()
|
|
|
|
|
|
# _drop_invalid_text_rows handles from_generator with column_names=None
|
|
|
|
|
|
def test_drop_invalid_text_rows_from_generator_none_column_names():
|
|
# from_generator IterableDatasets have column_names=None; resolve_column_names
|
|
# must fall back to first-row probe. _drop_invalid_text_rows must not raise
|
|
# TypeError and must filter correctly.
|
|
from utils.datasets.raw_text import _drop_invalid_text_rows
|
|
|
|
def _gen():
|
|
yield {"text": "valid row"}
|
|
yield {"text": None} # invalid, should be dropped
|
|
yield {"text": "another row"}
|
|
|
|
stream = datasets.IterableDataset.from_generator(_gen)
|
|
# Precondition: column_names is None on a raw from_generator dataset.
|
|
assert (
|
|
stream.column_names is None
|
|
), "precondition failed: expected column_names=None for from_generator dataset"
|
|
|
|
filtered, notices = _drop_invalid_text_rows(
|
|
stream, mode_title = "Raw text", split_scope = "test split"
|
|
)
|
|
|
|
rows = list(filtered)
|
|
assert [r["text"] for r in rows] == ["valid row", "another row"]
|
|
# At least one info/warning notice about dropped rows.
|
|
assert len(notices) >= 1
|
|
|
|
|
|
# _preflight_first_batch returns error string on empty dataloader
|
|
|
|
|
|
def test_preflight_first_batch_returns_error_on_empty_stream():
|
|
# StopIteration from an empty dataloader must return a clear
|
|
# error string (not None). Test via a minimal stub, no real model needed.
|
|
import types
|
|
import sys
|
|
|
|
# Minimal stub trainer whose get_train_dataloader() yields nothing.
|
|
class _EmptyLoader:
|
|
def __iter__(self):
|
|
return iter([])
|
|
|
|
class _StubTrainer:
|
|
def get_train_dataloader(self):
|
|
return _EmptyLoader()
|
|
|
|
# Load UnslothTrainer class from trainer.py via importlib to avoid heavy imports.
|
|
trainer_path = _BACKEND_ROOT / "core" / "training" / "trainer.py"
|
|
spec = importlib.util.spec_from_file_location("trainer_module", trainer_path)
|
|
trainer_mod = importlib.util.module_from_spec(spec)
|
|
# Provide a minimal sys.modules shim so top-level imports in trainer.py don't
|
|
# crash when optional heavy deps (torch, unsloth) are absent.
|
|
_orig_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__
|
|
|
|
try:
|
|
spec.loader.exec_module(trainer_mod)
|
|
except Exception:
|
|
# trainer.py has optional heavy imports; access _preflight_first_batch directly.
|
|
pass
|
|
|
|
# If we successfully loaded the module, find the trainer class.
|
|
trainer_cls = None
|
|
for name, obj in vars(trainer_mod).items() if "trainer_mod" in dir() else []:
|
|
# Only real classes — when heavy deps are stubbed with MagicMock,
|
|
# hasattr() is always True on a mock, so guard on isinstance(obj, type)
|
|
# to avoid picking a mock instance (object.__new__ would then reject it).
|
|
if isinstance(obj, type) and hasattr(obj, "_preflight_first_batch"):
|
|
trainer_cls = obj
|
|
break
|
|
|
|
if trainer_cls is None:
|
|
pytest.skip("Could not load trainer module (missing optional deps: torch/unsloth).")
|
|
|
|
# Build a bare instance without calling __init__ (avoids needing real deps).
|
|
instance = object.__new__(trainer_cls)
|
|
instance.trainer = _StubTrainer()
|
|
instance.model_name = "stub-model"
|
|
|
|
result = instance._preflight_first_batch()
|
|
|
|
assert result is not None, (
|
|
"_preflight_first_batch must return an error string (not None) when the "
|
|
"training dataloader is empty."
|
|
)
|
|
assert isinstance(result, str)
|
|
# The message should indicate there are no training rows / empty dataset.
|
|
assert any(kw in result.lower() for kw in ("empty", "no training", "no rows", "stream"))
|