chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""Shared pytest configuration for tests/python/."""
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "server: heavyweight tests requiring studio venv")
|
||||
config.addinivalue_line("markers", "e2e: end-to-end tests requiring network and venv creation")
|
||||
@@ -0,0 +1,71 @@
|
||||
import ast
|
||||
import re
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_change_system_message():
|
||||
# Extract _change_system_message without importing unsloth (needs unsloth_zoo / a GPU).
|
||||
source = Path(__file__).parents[2] / "unsloth" / "chat_templates.py"
|
||||
tree = ast.parse(source.read_text(encoding = "utf-8"))
|
||||
funcs = [
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "_change_system_message"
|
||||
]
|
||||
namespace = {
|
||||
"re": re,
|
||||
"logger": types.SimpleNamespace(warning_once = lambda *a, **k: None),
|
||||
"DEFAULT_SYSTEM_MESSAGE": {"unsloth": "You are a helpful assistant to the user"},
|
||||
}
|
||||
module = ast.Module(body = funcs, type_ignores = [])
|
||||
ast.fix_missing_locations(module)
|
||||
exec(compile(module, str(source), "exec"), namespace)
|
||||
return namespace["_change_system_message"]
|
||||
|
||||
|
||||
CUSTOM = "mycustom" # no predefined default
|
||||
|
||||
|
||||
def test_custom_template_fills_placeholder():
|
||||
# A {system_message} placeholder must be filled, not left literal.
|
||||
fn = _load_change_system_message()
|
||||
template, used = fn("System: {system_message}\nUser:", CUSTOM, "You are a pirate")
|
||||
assert template == "System: You are a pirate\nUser:"
|
||||
assert "{system_message}" not in template
|
||||
assert used == "You are a pirate"
|
||||
|
||||
|
||||
def test_custom_template_preserves_backslashes():
|
||||
# str.replace not re.sub: re.sub treats backslashes specially (r"C:\Users"
|
||||
# bad-escape, r"\1" group ref), so messages must be inserted verbatim.
|
||||
fn = _load_change_system_message()
|
||||
for msg in (r"C:\Users\me", r"\frac{a}{b}", r"see \1 here"):
|
||||
template, used = fn("System: {system_message}", CUSTOM, msg)
|
||||
assert template == f"System: {msg}"
|
||||
assert used == msg
|
||||
|
||||
|
||||
def test_custom_template_requires_system_message():
|
||||
# A placeholder with no system message must raise, not stay literal.
|
||||
fn = _load_change_system_message()
|
||||
with pytest.raises(ValueError):
|
||||
fn("System: {system_message}", CUSTOM, None)
|
||||
|
||||
|
||||
def test_custom_template_without_placeholder_unchanged():
|
||||
fn = _load_change_system_message()
|
||||
template, used = fn("System: fixed", CUSTOM, "ignored")
|
||||
assert template == "System: fixed"
|
||||
|
||||
|
||||
def test_predefined_template_uses_default_then_override():
|
||||
# Predefined templates with a default are unaffected.
|
||||
fn = _load_change_system_message()
|
||||
t1, u1 = fn("System: {system_message}", "unsloth", None)
|
||||
assert t1 == "System: You are a helpful assistant to the user"
|
||||
t2, u2 = fn("System: {system_message}", "unsloth", "Custom override")
|
||||
assert t2 == "System: Custom override"
|
||||
assert u2 == "Custom override"
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Negative-path validation tests for unsloth.chat_templates.construct_chat_template.
|
||||
|
||||
Regression coverage for the no-match guards added in the PR #5763 follow-up:
|
||||
missing placeholders or unrecoverable two-example structures must raise
|
||||
RuntimeError with a clear message (not IndexError/AttributeError) and must
|
||||
not silently drop the last char via s[:-1]. A minimal fake tokenizer keeps
|
||||
the cases CPU-only (no HF_TOKEN, no gated download).
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from unsloth.chat_templates import construct_chat_template
|
||||
|
||||
|
||||
class _FakeTokenizer:
|
||||
"""Minimal surface construct_chat_template touches before the guards fire."""
|
||||
|
||||
name_or_path = "fake/tokenizer"
|
||||
eos_token = "</s>"
|
||||
|
||||
def get_vocab(self):
|
||||
return {"</s>": 0}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"template, expected_in_message",
|
||||
[
|
||||
("only {INPUT} here, no output marker", "{OUTPUT}"),
|
||||
("only {OUTPUT} here, no input marker", "{INPUT}"),
|
||||
("neither sentinel here, just literal text", "{INPUT}"),
|
||||
("neither sentinel here, just literal text", "{OUTPUT}"),
|
||||
],
|
||||
)
|
||||
def test_missing_placeholder_in_chat_template_raises(template, expected_in_message):
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
construct_chat_template(
|
||||
tokenizer = _FakeTokenizer(),
|
||||
chat_template = template,
|
||||
extra_eos_tokens = ["</s>"],
|
||||
)
|
||||
assert expected_in_message in str(exc_info.value)
|
||||
|
||||
|
||||
def test_single_pair_template_raises_clear_error_not_attribute_error():
|
||||
"""A single {INPUT}/{OUTPUT} pair must raise RuntimeError, not the old
|
||||
AttributeError on `found.group(1)` when the loop broke without setting `found`."""
|
||||
template = "user: {INPUT}\nassistant: {OUTPUT}\n"
|
||||
with pytest.raises(RuntimeError):
|
||||
construct_chat_template(
|
||||
tokenizer = _FakeTokenizer(),
|
||||
chat_template = template,
|
||||
extra_eos_tokens = ["</s>"],
|
||||
)
|
||||
|
||||
|
||||
def test_error_message_excerpt_is_bounded():
|
||||
"""Error messages must include a bounded excerpt of the offending
|
||||
template, not dump arbitrarily large content into the traceback."""
|
||||
huge = ("garbage " * 5000) + "{INPUT}" # ~40 KB, missing {OUTPUT}
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
construct_chat_template(
|
||||
tokenizer = _FakeTokenizer(),
|
||||
chat_template = huge,
|
||||
extra_eos_tokens = ["</s>"],
|
||||
)
|
||||
msg = str(exc_info.value)
|
||||
# Excerpt is capped well under the template length.
|
||||
assert len(msg) < 1000
|
||||
assert "{OUTPUT}" in msg
|
||||
|
||||
|
||||
class _SuccessFakeTokenizer(_FakeTokenizer):
|
||||
"""Adds the surface construct_chat_template touches on the success path."""
|
||||
|
||||
bos_token = "<s>"
|
||||
bos_token_id = 1
|
||||
added_tokens_decoder: dict = {}
|
||||
|
||||
def __call__(self, text):
|
||||
# input_ids[0] must differ from bos_token_id so the BOS-handling branch is skipped.
|
||||
return SimpleNamespace(input_ids = [5])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"chat_template",
|
||||
[
|
||||
# User turn begins with {INPUT} (no prefix before the sentinel).
|
||||
"{INPUT} [/INST] {OUTPUT}</s>{INPUT} [/INST] {OUTPUT}</s>",
|
||||
# Assistant turn begins with {OUTPUT} (no prefix before the sentinel).
|
||||
"User: {INPUT}\n{OUTPUT}</s>User: {INPUT}\n{OUTPUT}</s>",
|
||||
],
|
||||
)
|
||||
def test_chat_template_does_not_leak_sentinel_when_section_starts_with_it(chat_template):
|
||||
"""When an input/output section begins with the {INPUT}/{OUTPUT} sentinel, the
|
||||
generated Jinja template must not keep the literal sentinel text. The `startswith`
|
||||
branch in the internal `process()` helper used to slice from `find()` (which is 0
|
||||
here) instead of past the sentinel, re-including the literal `{INPUT}`/`{OUTPUT}`."""
|
||||
_, jinja_template, _, _ = construct_chat_template(
|
||||
tokenizer = _SuccessFakeTokenizer(),
|
||||
chat_template = chat_template,
|
||||
extra_eos_tokens = ["</s>"],
|
||||
)
|
||||
assert "{INPUT}" not in jinja_template
|
||||
assert "{OUTPUT}" not in jinja_template
|
||||
@@ -0,0 +1,99 @@
|
||||
"""CPO shares ORPO's row-tokenization replacements (issue #4952).
|
||||
|
||||
CPOTrainer reuses ORPO's tokenize/init code, so the ORPO rewriters must also be
|
||||
registered for cpo_trainer. The rewriters themselves are covered by
|
||||
test_orpo_processor_text_tokenizer.py; here we just check cpo mirrors orpo.
|
||||
Static, CPU-only, no torch.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import os
|
||||
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
RL_PATH = os.path.join(REPO_ROOT, "unsloth", "models", "rl_replacements.py")
|
||||
|
||||
|
||||
def _registrations(source):
|
||||
"""Map each RL_FUNCTIONS[key] target to the appended function names."""
|
||||
out = {}
|
||||
for node in ast.walk(ast.parse(source)):
|
||||
if not isinstance(node, ast.Expr):
|
||||
continue
|
||||
call = node.value
|
||||
if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute)):
|
||||
continue
|
||||
if call.func.attr != "append":
|
||||
continue
|
||||
sub = call.func.value
|
||||
if not (isinstance(sub, ast.Subscript) and isinstance(sub.value, ast.Name)):
|
||||
continue
|
||||
if sub.value.id != "RL_FUNCTIONS":
|
||||
continue
|
||||
key = sub.slice
|
||||
if not (isinstance(key, ast.Constant) and isinstance(key.value, str)):
|
||||
continue
|
||||
arg = call.args[0]
|
||||
if isinstance(arg, ast.Name):
|
||||
out.setdefault(key.value, []).append(arg.id)
|
||||
return out
|
||||
|
||||
|
||||
def test_cpo_registration_matches_orpo():
|
||||
regs = _registrations(open(RL_PATH).read())
|
||||
shared = {"orpo_trainer_text_tokenizer", "orpo_trainer_processor_pad_token"}
|
||||
assert shared <= set(regs.get("orpo_trainer", []))
|
||||
assert shared <= set(regs.get("cpo_trainer", []))
|
||||
|
||||
|
||||
def _load_pad_rewriter():
|
||||
"""Exec orpo_trainer_processor_pad_token (+ _PAD_FALLBACK) without importing unsloth."""
|
||||
tree = ast.parse(open(RL_PATH).read())
|
||||
nodes = []
|
||||
for n in tree.body:
|
||||
if isinstance(n, ast.Assign) and any(
|
||||
getattr(t, "id", None) == "_PAD_FALLBACK" for t in n.targets
|
||||
):
|
||||
nodes.append(n)
|
||||
elif isinstance(n, ast.FunctionDef) and n.name == "orpo_trainer_processor_pad_token":
|
||||
nodes.append(n)
|
||||
import re as _re
|
||||
|
||||
ns = {"re": _re}
|
||||
exec(compile(ast.Module(body = nodes, type_ignores = []), RL_PATH, "exec"), ns)
|
||||
return ns["orpo_trainer_processor_pad_token"]
|
||||
|
||||
|
||||
def test_pad_token_default_routed_through_inner_tokenizer():
|
||||
# TRL 1.x CPO/ORPO __init__ defaults pad_token from eos_token before
|
||||
# tokenizing; on a multimodal processor those live on `.tokenizer`. The
|
||||
# rewrite must route both the default and pad_token_id through the inner
|
||||
# tokenizer so a processor without bare pad_token does not AttributeError.
|
||||
rewrite = _load_pad_rewriter()
|
||||
init_src = (
|
||||
"def __init__(self, model, args, processing_class):\n"
|
||||
" if processing_class.pad_token is None:\n"
|
||||
" processing_class.pad_token = processing_class.eos_token\n"
|
||||
" self.pad_token_id = processing_class.pad_token_id\n"
|
||||
)
|
||||
out = rewrite("__init__", init_src)
|
||||
assert "if processing_class.pad_token is None:" not in out
|
||||
assert "processing_class.pad_token = processing_class.eos_token" not in out
|
||||
assert "_unsloth_proc_tok = getattr(processing_class, 'tokenizer', processing_class)" in out
|
||||
# bare pad_token_id must be routed through the getattr fallback, not left raw
|
||||
assert "= processing_class.pad_token_id\n" not in out
|
||||
ast.parse(out) # rewritten source still compiles
|
||||
|
||||
|
||||
def test_pad_rewrite_noop_without_bare_pad_block():
|
||||
# Older TRL (the pinned <=0.24.0 range) has no bare pad_token block; the
|
||||
# rewrite must only touch pad_token_id and leave everything else intact.
|
||||
rewrite = _load_pad_rewriter()
|
||||
init_src = (
|
||||
"def __init__(self, model, args, processing_class):\n"
|
||||
" self.pad_token_id = processing_class.pad_token_id\n"
|
||||
)
|
||||
out = rewrite("__init__", init_src)
|
||||
assert "_unsloth_proc_tok" not in out
|
||||
assert "= processing_class.pad_token_id\n" not in out # still routed via fallback
|
||||
ast.parse(out)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Cross-platform parity tests between install.sh and install.ps1."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
INSTALL_SH = REPO_ROOT / "install.sh"
|
||||
INSTALL_PS1 = REPO_ROOT / "install.ps1"
|
||||
|
||||
|
||||
class TestNoTorchBackendAutoInInstallSh:
|
||||
"""install.sh primary paths must not use --torch-backend=auto (only the fallback else-branch may)."""
|
||||
|
||||
def test_no_torch_backend_auto_outside_fallback(self):
|
||||
lines = INSTALL_SH.read_text(encoding = "utf-8").splitlines()
|
||||
# Fallback block: from "GPU detection failed" to the next "fi".
|
||||
fallback_start = None
|
||||
fallback_end = None
|
||||
for i, line in enumerate(lines):
|
||||
if fallback_start is None and "GPU detection failed" in line:
|
||||
fallback_start = i
|
||||
elif fallback_start is not None and fallback_end is None and line.strip() == "fi":
|
||||
fallback_end = i
|
||||
break
|
||||
fallback_range = (
|
||||
range(fallback_start or 0, (fallback_end or 0) + 1) if fallback_start else range(0)
|
||||
)
|
||||
|
||||
matches = [
|
||||
(i + 1, line)
|
||||
for i, line in enumerate(lines)
|
||||
if "--torch-backend=auto" in line
|
||||
and not line.lstrip().startswith("#")
|
||||
and i not in fallback_range
|
||||
]
|
||||
assert matches == [], (
|
||||
f"install.sh contains --torch-backend=auto outside the fallback block at lines: "
|
||||
f"{[m[0] for m in matches]}"
|
||||
)
|
||||
|
||||
def test_fallback_uses_torch_backend_auto(self):
|
||||
"""The fallback branch should use --torch-backend=auto as recovery."""
|
||||
text = INSTALL_SH.read_text(encoding = "utf-8")
|
||||
assert (
|
||||
"GPU detection failed" in text
|
||||
), "install.sh should have a fallback branch for when GPU detection fails"
|
||||
|
||||
|
||||
class TestInstallShHasGpuDetection:
|
||||
"""install.sh must contain the get_torch_index_url function."""
|
||||
|
||||
def test_function_exists(self):
|
||||
text = INSTALL_SH.read_text(encoding = "utf-8")
|
||||
assert (
|
||||
"get_torch_index_url()" in text
|
||||
), "install.sh is missing the get_torch_index_url() function"
|
||||
|
||||
def test_torch_index_url_assigned(self):
|
||||
text = INSTALL_SH.read_text(encoding = "utf-8")
|
||||
assert (
|
||||
"TORCH_INDEX_URL=$(get_torch_index_url)" in text
|
||||
), "install.sh should assign TORCH_INDEX_URL from get_torch_index_url()"
|
||||
|
||||
|
||||
class TestCudaMappingParity:
|
||||
"""CUDA version thresholds must match between install.sh and install.ps1."""
|
||||
|
||||
@staticmethod
|
||||
def _extract_cuda_thresholds_sh(text: str) -> list[str]:
|
||||
"""Extract cu* suffixes from the major/minor comparison chain in install.sh."""
|
||||
# Only match lines in the if/elif chain that compare _major/_minor
|
||||
in_func = False
|
||||
results = []
|
||||
for line in text.splitlines():
|
||||
if "get_torch_index_url()" in line:
|
||||
in_func = True
|
||||
continue
|
||||
if in_func and line.startswith("}"):
|
||||
break
|
||||
if in_func and ("_major" in line or "_minor" in line):
|
||||
m = re.search(r"/(cu\d+|cpu)", line)
|
||||
if m:
|
||||
results.append(m.group(1))
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _extract_cuda_thresholds_ps1(text: str) -> list[str]:
|
||||
"""Extract cu* suffixes from the major/minor comparison chain in install.ps1."""
|
||||
in_func = False
|
||||
depth = 0
|
||||
results = []
|
||||
for line in text.splitlines():
|
||||
if "function Get-TorchIndexUrl" in line:
|
||||
in_func = True
|
||||
depth = 1
|
||||
continue
|
||||
if in_func:
|
||||
depth += line.count("{") - line.count("}")
|
||||
if depth <= 0:
|
||||
break
|
||||
# Only match the if-chain lines that compare $major/$minor
|
||||
if "$major" in line or "$minor" in line:
|
||||
m = re.search(r"/(cu\d+|cpu)", line)
|
||||
if m:
|
||||
results.append(m.group(1))
|
||||
return results
|
||||
|
||||
def test_same_cuda_suffixes(self):
|
||||
"""Both scripts should produce the same ordered list of CUDA index suffixes."""
|
||||
sh_text = INSTALL_SH.read_text(encoding = "utf-8")
|
||||
ps1_text = INSTALL_PS1.read_text(encoding = "utf-8")
|
||||
|
||||
sh_thresholds = self._extract_cuda_thresholds_sh(sh_text)
|
||||
ps1_thresholds = self._extract_cuda_thresholds_ps1(ps1_text)
|
||||
|
||||
assert len(sh_thresholds) > 0, "Could not extract thresholds from install.sh"
|
||||
assert len(ps1_thresholds) > 0, "Could not extract thresholds from install.ps1"
|
||||
assert sh_thresholds == ps1_thresholds, (
|
||||
f"CUDA mapping mismatch:\n"
|
||||
f" install.sh: {sh_thresholds}\n"
|
||||
f" install.ps1: {ps1_thresholds}"
|
||||
)
|
||||
|
||||
|
||||
class TestPyTorchMirrorEnvVar:
|
||||
"""Both install scripts must support the UNSLOTH_PYTORCH_MIRROR env var."""
|
||||
|
||||
def test_install_sh_has_mirror_var(self):
|
||||
text = INSTALL_SH.read_text(encoding = "utf-8")
|
||||
assert (
|
||||
"UNSLOTH_PYTORCH_MIRROR" in text
|
||||
), "install.sh should reference UNSLOTH_PYTORCH_MIRROR"
|
||||
|
||||
def test_install_ps1_has_mirror_var(self):
|
||||
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
||||
assert (
|
||||
"UNSLOTH_PYTORCH_MIRROR" in text
|
||||
), "install.ps1 should reference UNSLOTH_PYTORCH_MIRROR"
|
||||
|
||||
|
||||
class TestUvBytecodeCompileTimeout:
|
||||
"""Installers should relax uv bytecode compilation timeout by default."""
|
||||
|
||||
@staticmethod
|
||||
def _version_tuple(version: str) -> tuple[int, ...]:
|
||||
return tuple(int(part) for part in version.split("."))
|
||||
|
||||
def test_install_sh_uses_uv_version_with_timeout_env(self):
|
||||
text = INSTALL_SH.read_text(encoding = "utf-8")
|
||||
match = re.search(r'^UV_MIN_VERSION="([^"]+)"$', text, re.MULTILINE)
|
||||
assert match, "install.sh should declare UV_MIN_VERSION"
|
||||
assert self._version_tuple(match.group(1)) >= self._version_tuple("0.7.22")
|
||||
|
||||
def test_install_ps1_uses_uv_version_with_timeout_env(self):
|
||||
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
||||
match = re.search(r'^\s*\$UvMinVersion = "([^"]+)"$', text, re.MULTILINE)
|
||||
assert match, "install.ps1 should declare $UvMinVersion"
|
||||
assert self._version_tuple(match.group(1)) >= self._version_tuple("0.7.22")
|
||||
assert "function Test-UvVersionOk" in text
|
||||
assert "if (-not (Test-UvVersionOk))" in text
|
||||
|
||||
def test_install_sh_preserves_timeout_override(self):
|
||||
text = INSTALL_SH.read_text(encoding = "utf-8")
|
||||
assert (
|
||||
': "${UV_COMPILE_BYTECODE_TIMEOUT:=180}"' in text
|
||||
), "install.sh should default UV_COMPILE_BYTECODE_TIMEOUT without overwriting callers"
|
||||
assert (
|
||||
"export UV_COMPILE_BYTECODE_TIMEOUT" in text
|
||||
), "install.sh should export UV_COMPILE_BYTECODE_TIMEOUT for uv subprocesses"
|
||||
|
||||
def test_install_ps1_preserves_timeout_override(self):
|
||||
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
||||
assert (
|
||||
"if (-not $env:UV_COMPILE_BYTECODE_TIMEOUT)" in text
|
||||
), "install.ps1 should preserve caller UV_COMPILE_BYTECODE_TIMEOUT overrides"
|
||||
assert (
|
||||
'$env:UV_COMPILE_BYTECODE_TIMEOUT = "180"' in text
|
||||
), "install.ps1 should default UV_COMPILE_BYTECODE_TIMEOUT"
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Verify dpo_trainer_vision_process_row forwards prompt and images verbatim."""
|
||||
|
||||
import ast
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
RL_PATH = os.path.join(REPO_ROOT, "unsloth", "models", "rl_replacements.py")
|
||||
|
||||
|
||||
def _load_helpers():
|
||||
src = open(RL_PATH).read()
|
||||
tree = ast.parse(src)
|
||||
import torch as _torch
|
||||
|
||||
ns = {"torch": _torch}
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and any(
|
||||
isinstance(t, ast.Name) and t.id == "_DPO_VISION_KEYS" for t in node.targets
|
||||
):
|
||||
exec(ast.get_source_segment(src, node), ns)
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef) and node.name.startswith(
|
||||
("dpo_trainer_", "_dpo_trainer_")
|
||||
):
|
||||
exec(ast.get_source_segment(src, node), ns)
|
||||
return ns
|
||||
|
||||
|
||||
class _Tok:
|
||||
eos_token_id = 99
|
||||
bos_token_id = None
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
t,
|
||||
add_special_tokens = False,
|
||||
):
|
||||
return {"input_ids": [10]}
|
||||
|
||||
|
||||
class _Capture:
|
||||
image_token = "<img>"
|
||||
boi_token = "<boi>"
|
||||
|
||||
def __init__(self):
|
||||
self.tokenizer = _Tok()
|
||||
self.last_text = None
|
||||
self.last_images = "__sentinel__"
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
images = None,
|
||||
text = None,
|
||||
add_special_tokens = False,
|
||||
):
|
||||
self.last_text = text
|
||||
self.last_images = images
|
||||
out = {"input_ids": [[1, 2]]}
|
||||
if images is not None:
|
||||
out["pixel_values"] = [object()]
|
||||
return out
|
||||
|
||||
|
||||
def test_prompt_passes_through_without_image_token_synthesis():
|
||||
ns = _load_helpers()
|
||||
proc = _Capture()
|
||||
ns["dpo_trainer_vision_process_row"](
|
||||
{"prompt": "describe", "chosen": "c", "rejected": "r", "images": ["i"]},
|
||||
proc,
|
||||
)
|
||||
assert proc.last_text == "describe"
|
||||
|
||||
|
||||
def test_prompt_with_existing_image_token_unchanged():
|
||||
ns = _load_helpers()
|
||||
proc = _Capture()
|
||||
ns["dpo_trainer_vision_process_row"](
|
||||
{"prompt": "<img> describe", "chosen": "c", "rejected": "r", "images": ["i"]},
|
||||
proc,
|
||||
)
|
||||
assert proc.last_text == "<img> describe"
|
||||
|
||||
|
||||
def test_gemma3_style_boi_token_prompt_not_corrupted():
|
||||
ns = _load_helpers()
|
||||
proc = _Capture()
|
||||
ns["dpo_trainer_vision_process_row"](
|
||||
{"prompt": "<boi> describe", "chosen": "c", "rejected": "r", "images": ["i"]},
|
||||
proc,
|
||||
)
|
||||
assert proc.last_text == "<boi> describe"
|
||||
assert "<img>" not in proc.last_text
|
||||
|
||||
|
||||
def test_multi_image_prompt_unchanged_no_extra_placeholders():
|
||||
ns = _load_helpers()
|
||||
proc = _Capture()
|
||||
ns["dpo_trainer_vision_process_row"](
|
||||
{
|
||||
"prompt": "compare",
|
||||
"chosen": "c",
|
||||
"rejected": "r",
|
||||
"images": ["a", "b", "c"],
|
||||
},
|
||||
proc,
|
||||
)
|
||||
assert proc.last_text == "compare"
|
||||
|
||||
|
||||
def test_list_images_forwarded_verbatim():
|
||||
ns = _load_helpers()
|
||||
proc = _Capture()
|
||||
payload = ["a", "b"]
|
||||
ns["dpo_trainer_vision_process_row"](
|
||||
{"prompt": "p", "chosen": "c", "rejected": "r", "images": payload},
|
||||
proc,
|
||||
)
|
||||
assert proc.last_images is payload
|
||||
|
||||
|
||||
def test_single_pil_like_image_forwarded_verbatim():
|
||||
ns = _load_helpers()
|
||||
|
||||
class PIL:
|
||||
def __bool__(self):
|
||||
return True
|
||||
|
||||
proc = _Capture()
|
||||
pil = PIL()
|
||||
ns["dpo_trainer_vision_process_row"](
|
||||
{"prompt": "p", "chosen": "c", "rejected": "r", "images": pil},
|
||||
proc,
|
||||
)
|
||||
assert proc.last_images is pil
|
||||
|
||||
|
||||
def test_numpy_ndarray_image_forwarded_verbatim():
|
||||
ns = _load_helpers()
|
||||
proc = _Capture()
|
||||
arr = np.zeros((2, 3, 3), dtype = np.uint8)
|
||||
ns["dpo_trainer_vision_process_row"](
|
||||
{"prompt": "p", "chosen": "c", "rejected": "r", "images": arr},
|
||||
proc,
|
||||
)
|
||||
assert proc.last_images is arr
|
||||
|
||||
|
||||
def test_missing_images_key_passes_none_to_processor():
|
||||
ns = _load_helpers()
|
||||
proc = _Capture()
|
||||
ns["dpo_trainer_vision_process_row"](
|
||||
{"prompt": "p", "chosen": "c", "rejected": "r"},
|
||||
proc,
|
||||
)
|
||||
assert proc.last_images is None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,423 @@
|
||||
"""Text-only FastLanguageModel routing for vision-capable configs."""
|
||||
|
||||
import ast
|
||||
import copy
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
LOADER_PATH = REPO_ROOT / "unsloth" / "models" / "loader.py"
|
||||
VISION_PATH = REPO_ROOT / "unsloth" / "models" / "vision.py"
|
||||
UTILS_PATH = REPO_ROOT / "unsloth" / "models" / "_utils.py"
|
||||
|
||||
|
||||
def _source(path):
|
||||
return path.read_text()
|
||||
|
||||
|
||||
def _class_method(tree, class_name, method_name):
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.ClassDef) and node.name == class_name:
|
||||
for item in node.body:
|
||||
if isinstance(item, ast.FunctionDef) and item.name == method_name:
|
||||
return item
|
||||
raise AssertionError(f"{class_name}.{method_name} not found")
|
||||
|
||||
|
||||
def _assigns_name(method, target_name, predicate):
|
||||
"""True when the method contains `target_name = <value>` and predicate(value)."""
|
||||
for node in ast.walk(method):
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == target_name:
|
||||
if predicate(node.value):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _calls_function(method, func_name):
|
||||
"""True when the method calls `func_name(...)` (bare name, not attribute)."""
|
||||
for node in ast.walk(method):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == func_name
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _names_in(node):
|
||||
return {n.id for n in ast.walk(node) if isinstance(n, ast.Name)}
|
||||
|
||||
|
||||
def _param_default(method, name):
|
||||
# Default-value AST node for a named parameter, or None.
|
||||
args = method.args
|
||||
params = list(args.args) + list(args.kwonlyargs)
|
||||
defaults = list(args.defaults) + list(args.kw_defaults)
|
||||
return dict(zip([p.arg for p in params][-len(defaults) :], defaults)).get(name)
|
||||
|
||||
|
||||
def _load_text_only_namespace():
|
||||
# Exec the _utils text-only helpers into one namespace (no unsloth import),
|
||||
# in dependency order so cross-references resolve.
|
||||
source = _source(UTILS_PATH)
|
||||
import transformers
|
||||
from packaging.version import Version
|
||||
|
||||
ns = {
|
||||
"copy": copy,
|
||||
"Version": Version,
|
||||
"transformers_version": transformers.__version__,
|
||||
}
|
||||
funcs = {
|
||||
node.name: ast.get_source_segment(source, node)
|
||||
for node in ast.parse(source).body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
}
|
||||
for name in (
|
||||
"resolve_model_class",
|
||||
"_is_family_text_decoder",
|
||||
"_remap_text_only_skip_modules",
|
||||
"_get_text_only_config",
|
||||
"_get_text_only_key_mapping",
|
||||
"_apply_text_only_key_mapping",
|
||||
):
|
||||
if name in funcs:
|
||||
exec(funcs[name], ns)
|
||||
return ns
|
||||
|
||||
|
||||
def _load_text_only_helper():
|
||||
return _load_text_only_namespace()["_get_text_only_config"]
|
||||
|
||||
|
||||
def test_gemma3_vision_config_resolves_to_text_config():
|
||||
transformers = pytest.importorskip("transformers")
|
||||
helper = _load_text_only_helper()
|
||||
|
||||
config = transformers.Gemma3Config()
|
||||
text_config = helper(config, "google/gemma-3-27b-it")
|
||||
|
||||
assert isinstance(text_config, transformers.Gemma3TextConfig)
|
||||
assert text_config.model_type == "gemma3_text"
|
||||
model_class = transformers.AutoModelForCausalLM._model_mapping[type(text_config)]
|
||||
assert model_class.__name__ == "Gemma3ForCausalLM"
|
||||
|
||||
|
||||
def test_text_only_helper_rejects_configs_without_text_submodel():
|
||||
helper = _load_text_only_helper()
|
||||
|
||||
class VisionOnlyConfig:
|
||||
vision_config = object()
|
||||
|
||||
with pytest.raises(ValueError, match = "Cannot load vision-only as text-only"):
|
||||
helper(VisionOnlyConfig(), "vision-only")
|
||||
|
||||
|
||||
def test_fast_language_model_forwards_text_only_to_fast_model():
|
||||
source = _source(LOADER_PATH)
|
||||
method = _class_method(ast.parse(source), "FastLanguageModel", "from_pretrained")
|
||||
|
||||
# text_only defaults False (opt-in); both FastModel delegations forward it.
|
||||
text_only_default = _param_default(method, "text_only")
|
||||
assert isinstance(text_only_default, ast.Constant) and text_only_default.value is False
|
||||
|
||||
fast_model_calls = [
|
||||
node
|
||||
for node in ast.walk(method)
|
||||
if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "from_pretrained"
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == "FastModel"
|
||||
]
|
||||
assert len(fast_model_calls) == 2
|
||||
for call in fast_model_calls:
|
||||
kw = [k for k in call.keywords if k.arg == "text_only"]
|
||||
assert len(kw) == 1
|
||||
assert isinstance(kw[0].value, ast.Name) and kw[0].value.id == "text_only"
|
||||
|
||||
|
||||
def test_fast_model_text_only_does_not_override_explicit_auto_model():
|
||||
# AST-based so formatting/refactors that keep the structure do not break it.
|
||||
source = _source(LOADER_PATH)
|
||||
method = _class_method(ast.parse(source), "FastModel", "from_pretrained")
|
||||
|
||||
text_only_default = _param_default(method, "text_only")
|
||||
assert isinstance(text_only_default, ast.Constant) and text_only_default.value is False
|
||||
|
||||
# load_text_only is text_only AND a check that the caller did not pass auto_model.
|
||||
def _is_guarded_bool(value):
|
||||
names = _names_in(value)
|
||||
has_none_check = any(
|
||||
isinstance(n, ast.Compare) and any(isinstance(op, (ast.Is, ast.IsNot)) for op in n.ops)
|
||||
for n in ast.walk(value)
|
||||
)
|
||||
return "text_only" in names and "auto_model" in names and has_none_check
|
||||
|
||||
assert _assigns_name(method, "load_text_only", _is_guarded_bool)
|
||||
|
||||
assert _calls_function(method, "_get_text_only_config")
|
||||
|
||||
def _forwards_kwarg(node):
|
||||
return any(
|
||||
isinstance(n, ast.Call)
|
||||
and any(
|
||||
kw.arg == "text_only"
|
||||
and isinstance(kw.value, ast.Name)
|
||||
and kw.value.id == "load_text_only"
|
||||
for kw in n.keywords
|
||||
)
|
||||
for n in ast.walk(node)
|
||||
)
|
||||
|
||||
assert _forwards_kwarg(method)
|
||||
# Falls back to the full model unless the family has its own text decoder.
|
||||
assert _calls_function(method, "_is_family_text_decoder")
|
||||
assert _assigns_name(
|
||||
method,
|
||||
"load_text_only",
|
||||
lambda v: isinstance(v, ast.Constant) and v.value is False,
|
||||
)
|
||||
|
||||
|
||||
def test_fast_base_model_text_only_bypasses_vision_auto_model():
|
||||
source = _source(VISION_PATH)
|
||||
method = _class_method(ast.parse(source), "FastBaseModel", "from_pretrained")
|
||||
|
||||
text_only_default = _param_default(method, "text_only")
|
||||
assert isinstance(text_only_default, ast.Constant) and text_only_default.value is False
|
||||
|
||||
assert _assigns_name(
|
||||
method,
|
||||
"auto_model",
|
||||
lambda v: isinstance(v, ast.Name) and v.id == "AutoModelForCausalLM",
|
||||
)
|
||||
# Text-only path: strip config, apply the family guard, inject the key remap.
|
||||
assert _calls_function(method, "_get_text_only_config")
|
||||
assert _calls_function(method, "_is_family_text_decoder")
|
||||
assert _calls_function(method, "_apply_text_only_key_mapping")
|
||||
|
||||
|
||||
def test_gemma3_text_only_model_class_resolves_and_has_no_vision_tower():
|
||||
"""End-to-end: a tiny Gemma3 text-only model instantiates with text LM attrs and no vision tower."""
|
||||
transformers = pytest.importorskip("transformers")
|
||||
helper = _load_text_only_helper()
|
||||
|
||||
full_config = transformers.Gemma3Config()
|
||||
text_config = helper(full_config, "google/gemma-3-27b-it")
|
||||
|
||||
# Shrink for cheap CPU instantiation.
|
||||
text_config.num_hidden_layers = 1
|
||||
text_config.hidden_size = 32
|
||||
text_config.intermediate_size = 32
|
||||
text_config.num_attention_heads = 2
|
||||
text_config.num_key_value_heads = 1
|
||||
text_config.head_dim = 16
|
||||
text_config.vocab_size = 128
|
||||
|
||||
model_class = transformers.AutoModelForCausalLM._model_mapping[type(text_config)]
|
||||
model = model_class(text_config)
|
||||
|
||||
assert hasattr(model, "lm_head"), "text-only Gemma3 model should expose lm_head"
|
||||
|
||||
# No vision tower / multimodal projector remains.
|
||||
assert not hasattr(
|
||||
model, "vision_tower"
|
||||
), "text-only Gemma3 model should not have a vision_tower"
|
||||
assert not hasattr(
|
||||
model, "multi_modal_projector"
|
||||
), "text-only Gemma3 model should not have a multi_modal_projector"
|
||||
|
||||
|
||||
def test_helper_defined_once_in_utils_and_imported():
|
||||
# _get_text_only_config defined only in _utils, imported by loader + vision.
|
||||
def _defines(path):
|
||||
return any(
|
||||
isinstance(n, ast.FunctionDef) and n.name == "_get_text_only_config"
|
||||
for n in ast.parse(_source(path)).body
|
||||
)
|
||||
|
||||
def _imports(path):
|
||||
return any(
|
||||
isinstance(n, ast.ImportFrom)
|
||||
and n.module == "_utils"
|
||||
and any(a.name == "_get_text_only_config" for a in n.names)
|
||||
for n in ast.walk(ast.parse(_source(path)))
|
||||
)
|
||||
|
||||
assert _defines(UTILS_PATH)
|
||||
assert not _defines(LOADER_PATH) and _imports(LOADER_PATH)
|
||||
assert not _defines(VISION_PATH) and _imports(VISION_PATH)
|
||||
|
||||
|
||||
def _load_util_func(name):
|
||||
ns = _load_text_only_namespace()
|
||||
if name not in ns:
|
||||
raise AssertionError(f"{name} not found")
|
||||
return ns[name]
|
||||
|
||||
|
||||
def test_text_only_guard_predicate_across_vlm_families():
|
||||
# Text-only taken only when the resolved class remaps VLM weights.
|
||||
transformers = pytest.importorskip("transformers")
|
||||
from transformers import AutoModelForCausalLM
|
||||
|
||||
resolve = _load_util_func("resolve_model_class")
|
||||
is_family = _load_util_func("_is_family_text_decoder")
|
||||
helper = _load_text_only_helper()
|
||||
|
||||
def takes_text_only(cfg):
|
||||
text = helper(cfg, "x")
|
||||
return resolve(AutoModelForCausalLM, text) is not None and is_family(
|
||||
getattr(cfg, "model_type", ""), getattr(text, "model_type", "")
|
||||
)
|
||||
|
||||
# Dedicated text decoder remaps language_model.* -> strip vision.
|
||||
assert takes_text_only(transformers.Gemma3Config()) is True
|
||||
|
||||
# No text class (Qwen2-VL/Mllama) or a generic reused decoder that would
|
||||
# load random weights (Llava/PaliGemma/Idefics3/InternVL) -> keep full model.
|
||||
for name in [
|
||||
"Qwen2VLConfig",
|
||||
"Qwen2_5_VLConfig",
|
||||
"MllamaConfig",
|
||||
"LlavaConfig",
|
||||
"PaliGemmaConfig",
|
||||
"Idefics3Config",
|
||||
"InternVLConfig",
|
||||
]:
|
||||
cfg_cls = getattr(transformers, name, None)
|
||||
if cfg_cls is None:
|
||||
continue
|
||||
assert takes_text_only(cfg_cls()) is False, name
|
||||
|
||||
|
||||
def test_text_only_helper_preserves_quantization_config():
|
||||
# quantization_config must survive the strip so pre-quantized repos load. A
|
||||
# sentinel object avoids a bitsandbytes dependency on transformers 4.51.3.
|
||||
transformers = pytest.importorskip("transformers")
|
||||
helper = _load_text_only_helper()
|
||||
config = transformers.Gemma3Config()
|
||||
sentinel = object()
|
||||
config.quantization_config = sentinel
|
||||
text_config = helper(config, "google/gemma-3-27b-it")
|
||||
assert getattr(text_config, "quantization_config", None) is sentinel
|
||||
# The parent's shared text sub-config must not be mutated.
|
||||
assert getattr(config.get_text_config(), "quantization_config", None) is None
|
||||
|
||||
|
||||
def test_text_only_key_mapping_targets_published_prefixes():
|
||||
# Remap the published VLM decoder prefixes, applying only on transformers >=5
|
||||
# (on 4.x base_model_prefix handles it and a mapping hurts).
|
||||
transformers = pytest.importorskip("transformers")
|
||||
get_key_mapping = _load_util_func("_get_text_only_key_mapping")
|
||||
mapping = get_key_mapping(transformers.Gemma3Config(), transformers.Gemma3TextConfig())
|
||||
if int(transformers.__version__.split(".")[0]) < 5:
|
||||
assert mapping is None
|
||||
else:
|
||||
assert isinstance(mapping, dict)
|
||||
assert mapping.get(r"^language_model\.model\.") == "model." # gemma3
|
||||
assert mapping.get(r"^model\.language_model\.") == "model." # gemma3n
|
||||
assert mapping.get(r"^language_model\.lm_head\.") == "lm_head."
|
||||
|
||||
|
||||
def test_gemma3_text_only_loads_real_language_weights_from_vlm_checkpoint(tmp_path):
|
||||
# PR #5816: text-only loading of a Gemma 3 VLM checkpoint must load real
|
||||
# language weights, not random ones. Fails on tf >=5 without the key_mapping fix.
|
||||
transformers = pytest.importorskip("transformers")
|
||||
torch = pytest.importorskip("torch")
|
||||
import shutil
|
||||
from safetensors.torch import load_file, save_file
|
||||
|
||||
get_text_config = _load_text_only_helper()
|
||||
get_key_mapping = _load_util_func("_get_text_only_key_mapping")
|
||||
|
||||
sentinel = 0.1234
|
||||
text_cfg = transformers.Gemma3TextConfig(
|
||||
hidden_size = 32,
|
||||
intermediate_size = 64,
|
||||
num_hidden_layers = 1,
|
||||
num_attention_heads = 2,
|
||||
num_key_value_heads = 1,
|
||||
head_dim = 16,
|
||||
vocab_size = 128,
|
||||
max_position_embeddings = 128,
|
||||
sliding_window = 64,
|
||||
)
|
||||
vision_cfg = transformers.SiglipVisionConfig(
|
||||
hidden_size = 32,
|
||||
intermediate_size = 64,
|
||||
num_hidden_layers = 1,
|
||||
num_attention_heads = 2,
|
||||
image_size = 16,
|
||||
patch_size = 8,
|
||||
num_channels = 3,
|
||||
)
|
||||
full_config = transformers.Gemma3Config(
|
||||
text_config = text_cfg.to_dict(),
|
||||
vision_config = vision_cfg.to_dict(),
|
||||
)
|
||||
full_model = transformers.Gemma3ForConditionalGeneration(full_config)
|
||||
|
||||
state = full_model.state_dict()
|
||||
text_q = [
|
||||
k
|
||||
for k in state
|
||||
if "language_model" in k
|
||||
and "vision" not in k
|
||||
and k.endswith("layers.0.self_attn.q_proj.weight")
|
||||
]
|
||||
assert text_q, [k for k in state if "q_proj" in k][:5]
|
||||
with torch.no_grad():
|
||||
for k in text_q:
|
||||
state[k].fill_(sentinel)
|
||||
|
||||
save_dir = tmp_path / "vlm"
|
||||
full_model.save_pretrained(save_dir, safe_serialization = True)
|
||||
|
||||
# tf >=5 saves under an outer "model." prefix; strip it to reproduce the
|
||||
# language_model.model.* layout the published Gemma 3 checkpoints use.
|
||||
real_dir = tmp_path / "real"
|
||||
real_dir.mkdir()
|
||||
weights = {}
|
||||
for f in save_dir.glob("*.safetensors"):
|
||||
weights.update(load_file(str(f)))
|
||||
for f in save_dir.glob("*.bin"):
|
||||
weights.update(torch.load(f, map_location = "cpu", weights_only = True))
|
||||
weights = {
|
||||
(k[len("model.") :] if k.startswith("model.") else k): v.contiguous()
|
||||
for k, v in weights.items()
|
||||
}
|
||||
for p in save_dir.iterdir():
|
||||
if not p.name.endswith((".safetensors", ".bin", ".index.json")):
|
||||
shutil.copy(p, real_dir / p.name)
|
||||
save_file(weights, str(real_dir / "model.safetensors"))
|
||||
|
||||
text_config = get_text_config(full_config, "google/gemma-3-27b-it")
|
||||
load_kwargs = {}
|
||||
key_mapping = get_key_mapping(full_config, text_config)
|
||||
if key_mapping is not None:
|
||||
load_kwargs["key_mapping"] = key_mapping
|
||||
model = transformers.AutoModelForCausalLM.from_pretrained(
|
||||
real_dir,
|
||||
config = text_config,
|
||||
dtype = torch.float32,
|
||||
local_files_only = True,
|
||||
**load_kwargs,
|
||||
)
|
||||
|
||||
loaded = model.state_dict()
|
||||
q_key = [k for k in loaded if k.endswith("model.layers.0.self_attn.q_proj.weight")]
|
||||
assert q_key, "text decoder q_proj weight missing from the loaded model"
|
||||
assert float(loaded[q_key[0]].flatten()[0]) == pytest.approx(
|
||||
sentinel
|
||||
), "text weights were randomly initialized instead of loaded from the checkpoint"
|
||||
assert not any(
|
||||
"vision_tower" in n for n, _ in model.named_modules()
|
||||
), "vision tower should be skipped on the text-only path"
|
||||
@@ -0,0 +1,215 @@
|
||||
"""FastModel config passthrough and nested task config handling."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
LOADER_PATH = REPO_ROOT / "unsloth" / "models" / "loader.py"
|
||||
VISION_PATH = REPO_ROOT / "unsloth" / "models" / "vision.py"
|
||||
UTILS_PATH = REPO_ROOT / "unsloth" / "models" / "_utils.py"
|
||||
LLAMA_PATH = REPO_ROOT / "unsloth" / "models" / "llama.py"
|
||||
|
||||
|
||||
def _source(path):
|
||||
return path.read_text()
|
||||
|
||||
|
||||
def _class_method(tree, class_name, method_name):
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.ClassDef) and node.name == class_name:
|
||||
for item in node.body:
|
||||
if isinstance(item, ast.FunctionDef) and item.name == method_name:
|
||||
return item
|
||||
raise AssertionError(f"{class_name}.{method_name} not found")
|
||||
|
||||
|
||||
def _assigns_from_kwargs_pop(method, target_name, key_name):
|
||||
for node in ast.walk(method):
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
if not any(
|
||||
isinstance(target, ast.Name) and target.id == target_name for target in node.targets
|
||||
):
|
||||
continue
|
||||
value = node.value
|
||||
if not (
|
||||
isinstance(value, ast.Call)
|
||||
and isinstance(value.func, ast.Attribute)
|
||||
and value.func.attr == "pop"
|
||||
and isinstance(value.func.value, ast.Name)
|
||||
and value.func.value.id == "kwargs"
|
||||
and value.args
|
||||
and isinstance(value.args[0], ast.Constant)
|
||||
and value.args[0].value == key_name
|
||||
):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _calls_name(method, name):
|
||||
return any(
|
||||
isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == name
|
||||
for node in ast.walk(method)
|
||||
)
|
||||
|
||||
|
||||
def _load_task_attr_helper():
|
||||
source = _source(UTILS_PATH)
|
||||
funcs = {
|
||||
node.name: ast.get_source_segment(source, node)
|
||||
for node in ast.parse(source).body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
}
|
||||
ns = {}
|
||||
for name in ("_config_set", "set_task_config_attr"):
|
||||
exec(funcs[name], ns)
|
||||
return ns["set_task_config_attr"]
|
||||
|
||||
|
||||
def _load_loader_task_helpers():
|
||||
source = _source(LOADER_PATH)
|
||||
funcs = {
|
||||
node.name: ast.get_source_segment(source, node)
|
||||
for node in ast.parse(source).body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
}
|
||||
ns = {}
|
||||
for name in (
|
||||
"_config_get",
|
||||
"_config_diff",
|
||||
"_has_sequence_classification_architecture",
|
||||
"_get_user_task_config_attrs",
|
||||
):
|
||||
exec(funcs[name], ns)
|
||||
return ns["_get_user_task_config_attrs"]
|
||||
|
||||
|
||||
def test_fast_model_consumes_user_config_kwarg():
|
||||
tree = ast.parse(_source(LOADER_PATH))
|
||||
method = _class_method(tree, "FastModel", "from_pretrained")
|
||||
|
||||
assert _assigns_from_kwargs_pop(method, "user_config", "config")
|
||||
|
||||
|
||||
def test_fast_base_model_consumes_user_config_kwarg():
|
||||
tree = ast.parse(_source(VISION_PATH))
|
||||
method = _class_method(tree, "FastBaseModel", "from_pretrained")
|
||||
|
||||
assert _assigns_from_kwargs_pop(method, "user_config", "config")
|
||||
|
||||
|
||||
def test_fast_llama_model_consumes_user_config_kwarg():
|
||||
tree = ast.parse(_source(LLAMA_PATH))
|
||||
method = _class_method(tree, "FastLlamaModel", "from_pretrained")
|
||||
|
||||
assert _assigns_from_kwargs_pop(method, "user_config", "config")
|
||||
|
||||
|
||||
def test_fast_base_model_sets_task_attrs_on_nested_text_config():
|
||||
tree = ast.parse(_source(VISION_PATH))
|
||||
method = _class_method(tree, "FastBaseModel", "from_pretrained")
|
||||
|
||||
assert _calls_name(method, "set_task_config_attr")
|
||||
|
||||
|
||||
def test_fast_base_model_pops_problem_type_as_config_attr():
|
||||
source = _source(VISION_PATH)
|
||||
|
||||
assert '("id2label", "label2id", "problem_type")' in source
|
||||
|
||||
|
||||
def test_fast_model_uses_user_config_num_labels_for_task_model_selection():
|
||||
tree = ast.parse(_source(LOADER_PATH))
|
||||
method = _class_method(tree, "FastModel", "from_pretrained")
|
||||
|
||||
assert _calls_name(method, "_get_user_task_config_attrs")
|
||||
|
||||
|
||||
def test_fast_model_captures_user_config_num_labels_before_text_only_switch():
|
||||
source = _source(LOADER_PATH)
|
||||
|
||||
fallback = source.index("task_config_attrs = _get_user_task_config_attrs(user_config)")
|
||||
text_only_switch = source.index("model_config = text_config")
|
||||
|
||||
assert fallback < text_only_switch
|
||||
|
||||
|
||||
def test_user_task_config_attrs_ignore_default_num_labels():
|
||||
get_user_task_config_attrs = _load_loader_task_helpers()
|
||||
|
||||
class Config:
|
||||
num_labels = 2
|
||||
id2label = {0: "LABEL_0", 1: "LABEL_1"}
|
||||
label2id = {"LABEL_0": 0, "LABEL_1": 1}
|
||||
|
||||
def to_diff_dict(self):
|
||||
return {}
|
||||
|
||||
assert get_user_task_config_attrs(Config()) == {}
|
||||
|
||||
|
||||
def test_user_task_config_attrs_preserve_custom_label_maps():
|
||||
get_user_task_config_attrs = _load_loader_task_helpers()
|
||||
|
||||
class Config:
|
||||
num_labels = 2
|
||||
id2label = {0: "negative", 1: "positive"}
|
||||
label2id = {"negative": 0, "positive": 1}
|
||||
|
||||
def to_diff_dict(self):
|
||||
return {"id2label": self.id2label, "label2id": self.label2id}
|
||||
|
||||
attrs = get_user_task_config_attrs(Config())
|
||||
|
||||
assert attrs["num_labels"] == 2
|
||||
assert attrs["id2label"] == {0: "negative", 1: "positive"}
|
||||
assert attrs["label2id"] == {"negative": 0, "positive": 1}
|
||||
|
||||
|
||||
def test_user_task_config_attrs_preserve_explicit_dict_num_labels():
|
||||
get_user_task_config_attrs = _load_loader_task_helpers()
|
||||
|
||||
assert get_user_task_config_attrs({"num_labels": 2}) == {"num_labels": 2}
|
||||
|
||||
|
||||
def test_task_config_attr_updates_parent_and_text_config_objects():
|
||||
set_task_config_attr = _load_task_attr_helper()
|
||||
|
||||
class TextConfig:
|
||||
pass
|
||||
|
||||
class ParentConfig:
|
||||
def __init__(self):
|
||||
self.text_config = TextConfig()
|
||||
|
||||
def get_text_config(self):
|
||||
return self.text_config
|
||||
|
||||
config = ParentConfig()
|
||||
|
||||
set_task_config_attr(config, "num_labels", 3)
|
||||
|
||||
assert config.num_labels == 3
|
||||
assert config.text_config.num_labels == 3
|
||||
|
||||
|
||||
def test_task_config_attr_updates_parent_and_text_config_dicts():
|
||||
set_task_config_attr = _load_task_attr_helper()
|
||||
config = {"text_config": {}}
|
||||
|
||||
set_task_config_attr(config, "label2id", {"negative": 0, "positive": 1})
|
||||
|
||||
assert config["label2id"] == {"negative": 0, "positive": 1}
|
||||
assert config["text_config"]["label2id"] == {"negative": 0, "positive": 1}
|
||||
|
||||
|
||||
def test_task_config_attr_ignores_primitive_text_config():
|
||||
set_task_config_attr = _load_task_attr_helper()
|
||||
config = {"text_config": "not-a-config"}
|
||||
|
||||
set_task_config_attr(config, "num_labels", 2)
|
||||
|
||||
assert config["num_labels"] == 2
|
||||
assert config["text_config"] == "not-a-config"
|
||||
@@ -0,0 +1,122 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team.
|
||||
"""Regression guard for issue #6881: FastSentenceTransformer must preprocess text
|
||||
like a stock SentenceTransformer for decoder embedding models. ST 5.x infers a
|
||||
"message" modality for chat-template models (e.g. Qwen/Qwen3-Embedding), so building
|
||||
via `Transformer(model_name, ...)` chat-wraps inputs and degrades embeddings;
|
||||
`_create_transformer_module` uses `Transformer.load(...)` instead.
|
||||
|
||||
Layers: test_transformer_load_signature_supports_unsloth_kwargs (fast, runs when ST
|
||||
is importable) and test_fast_sentence_transformer_matches_stock_st (end-to-end parity,
|
||||
opt-in via UNSLOTH_EMBEDDING_PARITY_MODEL so default CI is unaffected).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_transformer_load_signature_supports_unsloth_kwargs():
|
||||
"""Forwards-compat tripwire: a Hub-capable Transformer.load must accept the kwargs
|
||||
the #6881 fix passes. Legacy ST 3.x/4.x expose load(input_path); the code falls back
|
||||
to Transformer(...) there, so mirror that gate and skip."""
|
||||
models = pytest.importorskip("sentence_transformers.models")
|
||||
load = getattr(models.Transformer, "load", None)
|
||||
assert callable(load), (
|
||||
"sentence_transformers Transformer.load is missing; the #6881 fix in "
|
||||
"unsloth.models.sentence_transformer._create_transformer_module depends on it."
|
||||
)
|
||||
params = inspect.signature(load).parameters
|
||||
accepts_var_kw = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
|
||||
# Mirror _create_transformer_module's hub_capable gate.
|
||||
hub_capable = accepts_var_kw or any(k in params for k in ("token", "cache_folder", "revision"))
|
||||
if not hub_capable:
|
||||
pytest.skip(
|
||||
"legacy Transformer.load(input_path); production path falls back to Transformer(...)"
|
||||
)
|
||||
unsupported = [
|
||||
k
|
||||
for k in ("token", "cache_folder", "revision", "trust_remote_code")
|
||||
if not (accepts_var_kw or k in params)
|
||||
]
|
||||
assert not unsupported, (
|
||||
f"installed sentence_transformers Transformer.load no longer accepts {unsupported} "
|
||||
f"and has no **kwargs; update _create_transformer_module (#6881) before it silently "
|
||||
f"falls back to Transformer(...)."
|
||||
)
|
||||
|
||||
|
||||
def _probe_texts():
|
||||
return [
|
||||
"roasted chickpeas in 20 kg bags",
|
||||
"The capital of France is Paris.",
|
||||
"A fast brown fox jumps over the lazy dog.",
|
||||
"recette de tarte aux pommes traditionnelle",
|
||||
]
|
||||
|
||||
|
||||
def test_fast_sentence_transformer_matches_stock_st():
|
||||
"""End-to-end: FastSentenceTransformer embeddings and tokenization must match a
|
||||
stock SentenceTransformer load of the same checkpoint. Opt-in (needs a model) and
|
||||
GPU-only (FastSentenceTransformer requires CUDA), so it skips on CPU-only runners."""
|
||||
model_id = os.environ.get("UNSLOTH_EMBEDDING_PARITY_MODEL")
|
||||
if not model_id:
|
||||
pytest.skip(
|
||||
"set UNSLOTH_EMBEDDING_PARITY_MODEL to a chat-template embedding model "
|
||||
"(HF id or local path) to run the #6881 parity test"
|
||||
)
|
||||
|
||||
torch = pytest.importorskip("torch")
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("FastSentenceTransformer requires CUDA; skipping on CPU-only runner")
|
||||
np = pytest.importorskip("numpy")
|
||||
pytest.importorskip("sentence_transformers")
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
device = "cuda"
|
||||
# Prefer bf16 when the GPU supports it: fp16 overflows to NaN on bf16-native
|
||||
# embedders such as EmbeddingGemma (Gemma3), which would mask real parity.
|
||||
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
||||
texts = _probe_texts()
|
||||
max_seq_length = 256
|
||||
|
||||
# Control FIRST, before importing unsloth, so its global import patches never
|
||||
# touch the stock reference (mirrors the issue's "restart runtime" repro).
|
||||
ctrl = SentenceTransformer(model_id, device = device, model_kwargs = {"torch_dtype": dtype})
|
||||
ctrl.max_seq_length = max_seq_length
|
||||
ctrl_ids = ctrl.tokenize([texts[0]])["input_ids"][0].tolist()
|
||||
ctrl_emb = np.asarray(
|
||||
ctrl.encode(texts, normalize_embeddings = True, batch_size = 8), dtype = np.float32
|
||||
)
|
||||
|
||||
import unsloth # noqa: F401
|
||||
from unsloth import FastSentenceTransformer
|
||||
|
||||
fast = FastSentenceTransformer.from_pretrained(
|
||||
model_id,
|
||||
max_seq_length = max_seq_length,
|
||||
dtype = dtype,
|
||||
load_in_4bit = False,
|
||||
load_in_16bit = True,
|
||||
)
|
||||
fast_ids = fast.tokenize([texts[0]])["input_ids"][0].tolist()
|
||||
fast_emb = np.asarray(
|
||||
fast.encode(texts, normalize_embeddings = True, batch_size = 8), dtype = np.float32
|
||||
)
|
||||
|
||||
# Identical tokenization = no chat-template wrapping slipped in (the #6881 defect).
|
||||
assert fast_ids == ctrl_ids, (
|
||||
f"tokenization diverged (chat-template wrapping regressed?):\n"
|
||||
f" stock: {ctrl_ids}\n fast: {fast_ids}"
|
||||
)
|
||||
|
||||
cos = (ctrl_emb * fast_emb).sum(1) / (
|
||||
np.linalg.norm(ctrl_emb, axis = 1) * np.linalg.norm(fast_emb, axis = 1)
|
||||
)
|
||||
assert float(cos.min()) > 0.99, (
|
||||
f"embedding parity regressed: min cosine {float(cos.min()):.5f} <= 0.99 "
|
||||
f"(per-text {[round(float(c), 5) for c in cos]})"
|
||||
)
|
||||
@@ -0,0 +1,233 @@
|
||||
"""FastSentenceTransformer constructor-redirect lifecycle: Auto*.from_pretrained
|
||||
are restored even when the Transformer constructor raises (try/finally), and
|
||||
`is_requested_model_name` matches HF IDs, local paths, trailing slashes, Path
|
||||
objects, and missing identifiers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _stub_module(name: str) -> types.ModuleType:
|
||||
# __spec__ set so find_spec(name) doesn't raise if a later test imports the real package.
|
||||
mod = types.ModuleType(name)
|
||||
mod.__spec__ = importlib.util.spec_from_loader(name, loader = None)
|
||||
return mod
|
||||
|
||||
|
||||
_STUB_KEYS = (
|
||||
"transformers",
|
||||
"sentence_transformers",
|
||||
"sentence_transformers.models",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _restore_sys_modules():
|
||||
"""Snapshot the stubbed module entries and restore them after each test so a
|
||||
downstream real `import transformers` doesn't pick up our stub."""
|
||||
saved = {k: sys.modules.get(k) for k in _STUB_KEYS}
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for k, v in saved.items():
|
||||
if v is None:
|
||||
sys.modules.pop(k, None)
|
||||
else:
|
||||
sys.modules[k] = v
|
||||
|
||||
|
||||
class _FakeAuto:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.from_pretrained = self._original
|
||||
|
||||
def _original(self, *args, **kwargs):
|
||||
return ("orig", self.name, args, kwargs)
|
||||
|
||||
|
||||
class _RecordingTransformerOk:
|
||||
last_calls = None
|
||||
|
||||
def __init__(self, model_name, **kwargs):
|
||||
from transformers import AutoModel, AutoProcessor, AutoTokenizer
|
||||
type(self).last_calls = {
|
||||
"model": AutoModel.from_pretrained(model_name),
|
||||
"processor": AutoProcessor.from_pretrained(model_name),
|
||||
"tokenizer": AutoTokenizer.from_pretrained(model_name),
|
||||
}
|
||||
|
||||
|
||||
class _RaisingTransformer:
|
||||
def __init__(self, *a, **kw):
|
||||
from transformers import AutoModel
|
||||
AutoModel.from_pretrained(a[0] if a else kw.get("model_name_or_path"))
|
||||
raise RuntimeError("simulated init failure")
|
||||
|
||||
|
||||
def _build_driver(transformer_class):
|
||||
transformers_mod = _stub_module("transformers")
|
||||
transformers_mod.AutoModel = _FakeAuto("AutoModel")
|
||||
transformers_mod.AutoProcessor = _FakeAuto("AutoProcessor")
|
||||
transformers_mod.AutoTokenizer = _FakeAuto("AutoTokenizer")
|
||||
sys.modules["transformers"] = transformers_mod
|
||||
|
||||
st_root = _stub_module("sentence_transformers")
|
||||
st_models = _stub_module("sentence_transformers.models")
|
||||
st_models.Transformer = transformer_class
|
||||
sys.modules["sentence_transformers"] = st_root
|
||||
sys.modules["sentence_transformers.models"] = st_models
|
||||
|
||||
captured = {"calls": None}
|
||||
|
||||
def driver(model_name, model, tokenizer):
|
||||
from transformers import AutoModel, AutoProcessor, AutoTokenizer
|
||||
from sentence_transformers.models import Transformer
|
||||
|
||||
def is_requested_model_name(args, kwargs):
|
||||
requested = None
|
||||
if args:
|
||||
requested = args[0]
|
||||
else:
|
||||
requested = kwargs.get("pretrained_model_name_or_path")
|
||||
if requested is None:
|
||||
requested = kwargs.get("model_name_or_path")
|
||||
if requested is None:
|
||||
return False
|
||||
try:
|
||||
requested = os.fspath(requested)
|
||||
expected = os.fspath(model_name)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if requested == expected:
|
||||
return True
|
||||
try:
|
||||
if os.path.exists(requested) or os.path.exists(expected):
|
||||
return os.path.abspath(requested) == os.path.abspath(expected)
|
||||
except (OSError, TypeError, ValueError):
|
||||
pass
|
||||
return False
|
||||
|
||||
original_model = AutoModel.from_pretrained
|
||||
original_processor = AutoProcessor.from_pretrained
|
||||
original_tokenizer = AutoTokenizer.from_pretrained
|
||||
|
||||
def return_existing_model(*a, **kw):
|
||||
return model if is_requested_model_name(a, kw) else original_model(*a, **kw)
|
||||
|
||||
def return_existing_tokenizer(*a, **kw):
|
||||
return tokenizer if is_requested_model_name(a, kw) else original_tokenizer(*a, **kw)
|
||||
|
||||
def return_existing_processor(*a, **kw):
|
||||
return tokenizer if is_requested_model_name(a, kw) else original_processor(*a, **kw)
|
||||
|
||||
try:
|
||||
AutoModel.from_pretrained = return_existing_model
|
||||
AutoProcessor.from_pretrained = return_existing_processor
|
||||
AutoTokenizer.from_pretrained = return_existing_tokenizer
|
||||
t = Transformer(model_name)
|
||||
captured["calls"] = getattr(type(t), "last_calls", None)
|
||||
return t
|
||||
finally:
|
||||
AutoModel.from_pretrained = original_model
|
||||
AutoProcessor.from_pretrained = original_processor
|
||||
AutoTokenizer.from_pretrained = original_tokenizer
|
||||
|
||||
return driver, transformers_mod, captured
|
||||
|
||||
|
||||
def test_redirect_substitutes_preloaded_objects_on_match():
|
||||
driver, _mod, captured = _build_driver(_RecordingTransformerOk)
|
||||
sentinel_model = object()
|
||||
sentinel_tok = object()
|
||||
driver("sentence-transformers/all-MiniLM-L6-v2", sentinel_model, sentinel_tok)
|
||||
calls = captured["calls"]
|
||||
assert calls["model"] is sentinel_model
|
||||
assert calls["processor"] is sentinel_tok
|
||||
assert calls["tokenizer"] is sentinel_tok
|
||||
|
||||
|
||||
def test_redirect_restored_on_constructor_exception():
|
||||
driver, transformers_mod, _ = _build_driver(_RaisingTransformer)
|
||||
pre_model = transformers_mod.AutoModel.from_pretrained
|
||||
pre_processor = transformers_mod.AutoProcessor.from_pretrained
|
||||
pre_tokenizer = transformers_mod.AutoTokenizer.from_pretrained
|
||||
|
||||
try:
|
||||
driver("model-id", object(), object())
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
assert transformers_mod.AutoModel.from_pretrained is pre_model
|
||||
assert transformers_mod.AutoProcessor.from_pretrained is pre_processor
|
||||
assert transformers_mod.AutoTokenizer.from_pretrained is pre_tokenizer
|
||||
|
||||
|
||||
def test_redirect_passes_through_for_other_model_names():
|
||||
class _OtherNameTransformer:
|
||||
captured = None
|
||||
|
||||
def __init__(self, model_name, **kw):
|
||||
from transformers import AutoModel
|
||||
type(self).captured = AutoModel.from_pretrained("some-other/aux-model")
|
||||
|
||||
driver, *_ = _build_driver(_OtherNameTransformer)
|
||||
sentinel = object()
|
||||
driver("primary/model-id", sentinel, object())
|
||||
assert _OtherNameTransformer.captured is not sentinel
|
||||
assert isinstance(_OtherNameTransformer.captured, tuple)
|
||||
assert _OtherNameTransformer.captured[0] == "orig"
|
||||
|
||||
|
||||
def test_is_requested_model_name_handles_pathlib_path(tmp_path):
|
||||
target = tmp_path / "model_dir"
|
||||
target.mkdir()
|
||||
|
||||
class _PathTransformer:
|
||||
last_calls = None
|
||||
|
||||
def __init__(self, model_name, **kw):
|
||||
from transformers import AutoModel
|
||||
type(self).last_calls = AutoModel.from_pretrained(pathlib.Path(model_name))
|
||||
|
||||
driver, *_ = _build_driver(_PathTransformer)
|
||||
sentinel_model = object()
|
||||
driver(str(target), sentinel_model, object())
|
||||
assert _PathTransformer.last_calls is sentinel_model
|
||||
|
||||
|
||||
def test_is_requested_model_name_trailing_slash_local_path(tmp_path):
|
||||
target = tmp_path / "model_dir"
|
||||
target.mkdir()
|
||||
|
||||
class _SlashTransformer:
|
||||
last_calls = None
|
||||
|
||||
def __init__(self, model_name, **kw):
|
||||
from transformers import AutoModel
|
||||
type(self).last_calls = AutoModel.from_pretrained(str(target) + "/")
|
||||
|
||||
driver, *_ = _build_driver(_SlashTransformer)
|
||||
sentinel_model = object()
|
||||
driver(str(target), sentinel_model, object())
|
||||
assert _SlashTransformer.last_calls is sentinel_model
|
||||
|
||||
|
||||
def test_is_requested_model_name_returns_false_when_no_identifier():
|
||||
captured = {"args": None}
|
||||
|
||||
class _NoNameTransformer:
|
||||
def __init__(self, model_name, **kw):
|
||||
from transformers import AutoModel
|
||||
captured["args"] = AutoModel.from_pretrained(some_other_kwarg = "x")
|
||||
|
||||
driver, *_ = _build_driver(_NoNameTransformer)
|
||||
driver("primary/model-id", object(), object())
|
||||
assert isinstance(captured["args"], tuple)
|
||||
assert captured["args"][0] == "orig"
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Tests for the optional FlashAttention installer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
STUDIO_DIR = Path(__file__).resolve().parents[2] / "studio"
|
||||
sys.path.insert(0, str(STUDIO_DIR))
|
||||
sys.path.insert(0, str(STUDIO_DIR / "backend"))
|
||||
|
||||
import install_python_stack as ips
|
||||
from utils import wheel_utils
|
||||
|
||||
|
||||
class TestPrebuiltWheelTorchMapping:
|
||||
def test_torch_211_maps_to_torch210(self):
|
||||
assert wheel_utils.prebuilt_wheel_torch_mm("2.11") == "2.10"
|
||||
|
||||
def test_other_versions_pass_through(self):
|
||||
for torch_mm in ("2.9", "2.10", "2.12"):
|
||||
assert wheel_utils.prebuilt_wheel_torch_mm(torch_mm) == torch_mm
|
||||
|
||||
def test_direct_wheel_url_reuses_torch210_on_211(self):
|
||||
# causal-conv1d / mamba go through direct_wheel_url; torch 2.11 reuses the
|
||||
# torch2.10 wheel filename just like flash-attn does.
|
||||
url = wheel_utils.direct_wheel_url(
|
||||
filename_prefix = "causal_conv1d",
|
||||
package_version = "1.6.1",
|
||||
release_tag = "v1.6.1.post4",
|
||||
release_base_url = "https://example.test/download",
|
||||
env = {
|
||||
"python_tag": "cp313",
|
||||
"torch_mm": "2.11",
|
||||
"cuda_major": "13",
|
||||
"cxx11abi": "TRUE",
|
||||
"platform_tag": "linux_x86_64",
|
||||
},
|
||||
)
|
||||
assert url is not None
|
||||
assert "causal_conv1d-1.6.1+cu13torch2.10cxx11abiTRUE-cp313-cp313-linux_x86_64.whl" in url
|
||||
|
||||
|
||||
class TestFlashAttnWheelSelection:
|
||||
def test_torch_210_maps_to_v281(self):
|
||||
assert ips._select_flash_attn_version("2.10") == "2.8.1"
|
||||
|
||||
def test_torch_29_maps_to_v283(self):
|
||||
assert ips._select_flash_attn_version("2.9") == "2.8.3"
|
||||
|
||||
def test_torch_211_has_no_native_version_entry(self):
|
||||
# The raw version table has no torch2.11-tagged wheel; the URL builder
|
||||
# reuses the torch2.10 wheel instead (see test_torch_211_reuses_torch210_wheel).
|
||||
assert ips._select_flash_attn_version("2.11") is None
|
||||
|
||||
def test_torch_211_reuses_torch210_wheel(self):
|
||||
url = ips._build_flash_attn_wheel_url(
|
||||
{
|
||||
"python_tag": "cp313",
|
||||
"torch_mm": "2.11",
|
||||
"cuda_major": "13",
|
||||
"cxx11abi": "TRUE",
|
||||
"platform_tag": "linux_x86_64",
|
||||
}
|
||||
)
|
||||
assert url is not None
|
||||
assert "flash_attn-2.8.1+cu13torch2.10cxx11abiTRUE-cp313-cp313-linux_x86_64.whl" in url
|
||||
|
||||
def test_exact_wheel_url_uses_full_env_tuple(self):
|
||||
url = ips._build_flash_attn_wheel_url(
|
||||
{
|
||||
"python_tag": "cp313",
|
||||
"torch_mm": "2.10",
|
||||
"cuda_major": "12",
|
||||
"cxx11abi": "TRUE",
|
||||
"platform_tag": "linux_x86_64",
|
||||
}
|
||||
)
|
||||
assert url is not None
|
||||
assert "v2.8.1" in url
|
||||
assert "flash_attn-2.8.1+cu12torch2.10cxx11abiTRUE-cp313-cp313-linux_x86_64.whl" in url
|
||||
|
||||
def test_missing_cuda_major_disables_wheel_lookup(self):
|
||||
assert (
|
||||
ips._build_flash_attn_wheel_url(
|
||||
{
|
||||
"python_tag": "cp313",
|
||||
"torch_mm": "2.10",
|
||||
"cuda_major": "",
|
||||
"cxx11abi": "TRUE",
|
||||
"platform_tag": "linux_x86_64",
|
||||
}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
class TestEnsureFlashAttn:
|
||||
def _import_check(self, code: int = 1):
|
||||
return subprocess.CompletedProcess(["python", "-c", "import flash_attn"], code)
|
||||
|
||||
def test_prefers_exact_match_wheel(self):
|
||||
install_calls = []
|
||||
|
||||
def fake_install_wheel(*args, **kwargs):
|
||||
install_calls.append((args, kwargs))
|
||||
return [("uv", subprocess.CompletedProcess(["uv"], 0, ""))]
|
||||
|
||||
with (
|
||||
mock.patch.object(ips, "NO_TORCH", False),
|
||||
mock.patch.object(ips, "IS_WINDOWS", False),
|
||||
mock.patch.object(ips, "IS_MACOS", False),
|
||||
mock.patch.object(ips, "USE_UV", True),
|
||||
mock.patch.object(ips, "UV_NEEDS_SYSTEM", False),
|
||||
mock.patch.object(
|
||||
ips,
|
||||
"probe_torch_wheel_env",
|
||||
return_value = {
|
||||
"python_tag": "cp313",
|
||||
"torch_mm": "2.10",
|
||||
"cuda_major": "12",
|
||||
"cxx11abi": "TRUE",
|
||||
"platform_tag": "linux_x86_64",
|
||||
},
|
||||
),
|
||||
mock.patch.object(ips, "url_exists", return_value = True),
|
||||
mock.patch.object(ips, "install_wheel", side_effect = fake_install_wheel),
|
||||
mock.patch("subprocess.run", return_value = self._import_check()),
|
||||
):
|
||||
ips._ensure_flash_attn()
|
||||
|
||||
assert len(install_calls) == 1
|
||||
args, kwargs = install_calls[0]
|
||||
assert args == (
|
||||
"https://github.com/Dao-AILab/flash-attention/releases/download/v2.8.1/flash_attn-2.8.1+cu12torch2.10cxx11abiTRUE-cp313-cp313-linux_x86_64.whl",
|
||||
)
|
||||
assert kwargs["python_executable"] == sys.executable
|
||||
assert kwargs["use_uv"] is True
|
||||
assert kwargs["uv_needs_system"] is False
|
||||
|
||||
def test_uv_install_respects_system_flag(self):
|
||||
install_calls = []
|
||||
|
||||
def fake_install_wheel(*args, **kwargs):
|
||||
install_calls.append((args, kwargs))
|
||||
return [("uv", subprocess.CompletedProcess(["uv"], 0, ""))]
|
||||
|
||||
with (
|
||||
mock.patch.object(ips, "NO_TORCH", False),
|
||||
mock.patch.object(ips, "IS_WINDOWS", False),
|
||||
mock.patch.object(ips, "IS_MACOS", False),
|
||||
mock.patch.object(ips, "USE_UV", True),
|
||||
mock.patch.object(ips, "UV_NEEDS_SYSTEM", True),
|
||||
mock.patch.object(
|
||||
ips,
|
||||
"probe_torch_wheel_env",
|
||||
return_value = {
|
||||
"python_tag": "cp313",
|
||||
"torch_mm": "2.10",
|
||||
"cuda_major": "12",
|
||||
"cxx11abi": "TRUE",
|
||||
"platform_tag": "linux_x86_64",
|
||||
},
|
||||
),
|
||||
mock.patch.object(ips, "url_exists", return_value = True),
|
||||
mock.patch.object(ips, "install_wheel", side_effect = fake_install_wheel),
|
||||
mock.patch("subprocess.run", return_value = self._import_check()),
|
||||
):
|
||||
ips._ensure_flash_attn()
|
||||
|
||||
assert len(install_calls) == 1
|
||||
_, kwargs = install_calls[0]
|
||||
assert kwargs["uv_needs_system"] is True
|
||||
|
||||
def test_wheel_failure_warns_and_continues(self):
|
||||
step_messages: list[tuple[str, str]] = []
|
||||
printed_failures: list[str] = []
|
||||
|
||||
def fake_step(
|
||||
label: str,
|
||||
value: str,
|
||||
color_fn = None,
|
||||
):
|
||||
step_messages.append((label, value))
|
||||
|
||||
with (
|
||||
mock.patch.object(ips, "NO_TORCH", False),
|
||||
mock.patch.object(ips, "IS_WINDOWS", False),
|
||||
mock.patch.object(ips, "IS_MACOS", False),
|
||||
mock.patch.object(ips, "USE_UV", True),
|
||||
mock.patch.object(ips, "UV_NEEDS_SYSTEM", False),
|
||||
mock.patch.object(
|
||||
ips,
|
||||
"probe_torch_wheel_env",
|
||||
return_value = {
|
||||
"python_tag": "cp313",
|
||||
"torch_mm": "2.10",
|
||||
"cuda_major": "12",
|
||||
"cxx11abi": "TRUE",
|
||||
"platform_tag": "linux_x86_64",
|
||||
},
|
||||
),
|
||||
mock.patch.object(ips, "url_exists", return_value = True),
|
||||
mock.patch.object(
|
||||
ips,
|
||||
"install_wheel",
|
||||
return_value = [
|
||||
("uv", subprocess.CompletedProcess(["uv"], 1, "uv wheel failed")),
|
||||
(
|
||||
"pip",
|
||||
subprocess.CompletedProcess(["pip"], 1, "pip wheel failed"),
|
||||
),
|
||||
],
|
||||
),
|
||||
mock.patch.object(
|
||||
ips,
|
||||
"_print_optional_install_failure",
|
||||
side_effect = lambda label, result: printed_failures.append(label),
|
||||
),
|
||||
mock.patch.object(ips, "_step", side_effect = fake_step),
|
||||
mock.patch("subprocess.run", return_value = self._import_check()),
|
||||
):
|
||||
ips._ensure_flash_attn()
|
||||
|
||||
assert printed_failures == [
|
||||
"Installing flash-attn prebuilt wheel with uv",
|
||||
"Installing flash-attn prebuilt wheel with pip",
|
||||
]
|
||||
assert ("warning", "Continuing without flash-attn") in step_messages
|
||||
|
||||
def test_wheel_missing_skips_install_at_setup_time(self):
|
||||
step_messages: list[tuple[str, str]] = []
|
||||
|
||||
def fake_step(
|
||||
label: str,
|
||||
value: str,
|
||||
color_fn = None,
|
||||
):
|
||||
step_messages.append((label, value))
|
||||
|
||||
with (
|
||||
mock.patch.object(ips, "NO_TORCH", False),
|
||||
mock.patch.object(ips, "IS_WINDOWS", False),
|
||||
mock.patch.object(ips, "IS_MACOS", False),
|
||||
mock.patch.object(
|
||||
ips,
|
||||
"probe_torch_wheel_env",
|
||||
return_value = {
|
||||
"python_tag": "cp313",
|
||||
"torch_mm": "2.10",
|
||||
"cuda_major": "13",
|
||||
"cxx11abi": "TRUE",
|
||||
"platform_tag": "linux_x86_64",
|
||||
},
|
||||
),
|
||||
mock.patch.object(ips, "url_exists", return_value = False),
|
||||
mock.patch.object(ips, "install_wheel") as mock_install_wheel,
|
||||
mock.patch.object(ips, "_step", side_effect = fake_step),
|
||||
mock.patch("subprocess.run", return_value = self._import_check()),
|
||||
):
|
||||
ips._ensure_flash_attn()
|
||||
|
||||
mock_install_wheel.assert_not_called()
|
||||
assert ("warning", "No published flash-attn prebuilt wheel found") in step_messages
|
||||
|
||||
def test_skip_env_disables_setup_install(self):
|
||||
with (
|
||||
mock.patch.object(ips, "NO_TORCH", False),
|
||||
mock.patch.object(ips, "IS_WINDOWS", False),
|
||||
mock.patch.object(ips, "IS_MACOS", False),
|
||||
mock.patch.dict(os.environ, {"UNSLOTH_STUDIO_SKIP_FLASHATTN_INSTALL": "1"}),
|
||||
mock.patch.object(ips, "probe_torch_wheel_env") as mock_probe,
|
||||
mock.patch.object(ips, "install_wheel") as mock_install_wheel,
|
||||
mock.patch("subprocess.run", return_value = self._import_check()),
|
||||
):
|
||||
ips._ensure_flash_attn()
|
||||
|
||||
mock_probe.assert_not_called()
|
||||
mock_install_wheel.assert_not_called()
|
||||
|
||||
def test_windows_skips_install_without_probing(self):
|
||||
# flash-attn is Linux-only: on Windows the installer returns before
|
||||
# probing the torch env or resolving a wheel (no Windows wheels are
|
||||
# published upstream).
|
||||
with (
|
||||
mock.patch.object(ips, "NO_TORCH", False),
|
||||
mock.patch.object(ips, "IS_WINDOWS", True),
|
||||
mock.patch.object(ips, "IS_MACOS", False),
|
||||
mock.patch.object(ips, "probe_torch_wheel_env") as mock_probe,
|
||||
mock.patch.object(ips, "install_wheel") as mock_install_wheel,
|
||||
mock.patch("subprocess.run", return_value = self._import_check()),
|
||||
):
|
||||
ips._ensure_flash_attn()
|
||||
|
||||
mock_probe.assert_not_called()
|
||||
mock_install_wheel.assert_not_called()
|
||||
|
||||
|
||||
class TestInstallPythonStackFlashAttnIntegration:
|
||||
def _run_install(self, *, no_torch: bool, is_macos: bool, is_windows: bool) -> int:
|
||||
flash_attn_calls = 0
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
return subprocess.CompletedProcess(cmd, 0, b"", b"")
|
||||
|
||||
def count_flash_attn():
|
||||
nonlocal flash_attn_calls
|
||||
flash_attn_calls += 1
|
||||
|
||||
with (
|
||||
mock.patch.object(ips, "NO_TORCH", no_torch),
|
||||
mock.patch.object(ips, "IS_MACOS", is_macos),
|
||||
mock.patch.object(ips, "IS_WINDOWS", is_windows),
|
||||
mock.patch.object(ips, "USE_UV", True),
|
||||
mock.patch.object(ips, "UV_NEEDS_SYSTEM", False),
|
||||
mock.patch.object(ips, "VERBOSE", False),
|
||||
mock.patch.object(ips, "_bootstrap_uv", return_value = True),
|
||||
mock.patch.object(ips, "_ensure_flash_attn", side_effect = count_flash_attn),
|
||||
mock.patch("subprocess.run", side_effect = fake_run),
|
||||
mock.patch.object(ips, "_has_usable_nvidia_gpu", return_value = False),
|
||||
mock.patch.object(ips, "_has_rocm_gpu", return_value = False),
|
||||
mock.patch.object(ips, "LOCAL_DD_UNSTRUCTURED_PLUGIN", Path("/fake/plugin")),
|
||||
mock.patch("pathlib.Path.is_dir", return_value = True),
|
||||
mock.patch("pathlib.Path.is_file", return_value = True),
|
||||
mock.patch.dict(os.environ, {"SKIP_STUDIO_BASE": "1"}, clear = False),
|
||||
):
|
||||
ips.install_python_stack()
|
||||
|
||||
return flash_attn_calls
|
||||
|
||||
def test_linux_torch_install_calls_flash_attn_step(self):
|
||||
assert self._run_install(no_torch = False, is_macos = False, is_windows = False) == 1
|
||||
|
||||
def test_no_torch_install_skips_flash_attn_step(self):
|
||||
assert self._run_install(no_torch = True, is_macos = False, is_windows = False) == 0
|
||||
|
||||
def test_macos_install_skips_flash_attn_step(self):
|
||||
assert self._run_install(no_torch = False, is_macos = True, is_windows = False) == 0
|
||||
|
||||
def test_windows_install_skips_flash_attn_step(self):
|
||||
assert self._run_install(no_torch = False, is_macos = False, is_windows = True) == 0
|
||||
@@ -0,0 +1,44 @@
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
GPU_INIT = REPO_ROOT / "unsloth" / "_gpu_init.py"
|
||||
|
||||
|
||||
def _find_geteuid_guard(tree: ast.AST):
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.If):
|
||||
continue
|
||||
for sub in ast.walk(node.test):
|
||||
if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute):
|
||||
if sub.func.attr == "geteuid":
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def test_gpu_init_has_geteuid_guard():
|
||||
tree = ast.parse(GPU_INIT.read_text())
|
||||
guard = _find_geteuid_guard(tree)
|
||||
assert guard is not None, "_gpu_init.py must guard ldconfig recovery on os.geteuid()"
|
||||
|
||||
|
||||
def test_ldconfig_calls_only_inside_geteuid_guard():
|
||||
src = GPU_INIT.read_text()
|
||||
tree = ast.parse(src)
|
||||
guard = _find_geteuid_guard(tree)
|
||||
assert guard is not None
|
||||
guard_src = ast.get_source_segment(src, guard) or ""
|
||||
ldconfig_lines = [
|
||||
line for line in src.splitlines() if "ldconfig" in line and "os.system" in line
|
||||
]
|
||||
for line in ldconfig_lines:
|
||||
assert line.strip() in guard_src, (
|
||||
"os.system('ldconfig ...') must live inside the geteuid guard, "
|
||||
f"but found unguarded: {line!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_non_root_branch_warns_when_bnb_present():
|
||||
src = GPU_INIT.read_text()
|
||||
assert "elif bnb is not None" in src
|
||||
assert "sudo ldconfig" in src
|
||||
@@ -0,0 +1,31 @@
|
||||
"""GRPO logit-scaling helpers must read config through DDP wrappers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir))
|
||||
SOURCE_PATH = os.path.join(REPO_ROOT, "unsloth", "models", "rl_replacements.py")
|
||||
|
||||
|
||||
def _read_source() -> str:
|
||||
with open(SOURCE_PATH, "r") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def test_grpo_logit_scaling_uses_model_config_helper():
|
||||
src = _read_source()
|
||||
# Helper exists and unwraps DDP/Accelerate wrappers via `.module`.
|
||||
assert "def _unsloth_get_model_config(model):" in src
|
||||
assert 'getattr(model.module, "config", None)' in src
|
||||
# Softcapping takes the model and tolerates a missing config.
|
||||
assert "logit_softcapping = _unsloth_get_final_logit_softcapping(model)" in src
|
||||
assert "if config is None:" in src.split("def _unsloth_get_final_logit_softcapping")[1]
|
||||
# Logit scale/divide read through the unwrapped config, not bare model.config.
|
||||
assert 'getattr(model_config, "logit_scale", 0)' in src
|
||||
assert 'getattr(model_config, "logits_scaling", 0)' in src
|
||||
assert src.count("model_config = _unsloth_get_model_config(model)") >= 2
|
||||
# Helper source is injected into the compiled GRPO trainer.
|
||||
assert "inspect.getsource(_unsloth_get_model_config)" in src
|
||||
# No direct model.config access remains in the RL logit path.
|
||||
assert "model.config" not in src
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Tests for install_python_stack._build_uv_cmd torch-backend handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
STUDIO_DIR = Path(__file__).resolve().parents[2] / "studio"
|
||||
sys.path.insert(0, str(STUDIO_DIR))
|
||||
|
||||
import install_python_stack as ips
|
||||
|
||||
|
||||
class TestBuildUvCmdTorchBackend:
|
||||
"""Verify _build_uv_cmd only adds --torch-backend when UV_TORCH_BACKEND is set."""
|
||||
|
||||
def _call(self, args: tuple[str, ...] = ()) -> list[str]:
|
||||
return ips._build_uv_cmd(args)
|
||||
|
||||
def test_default_no_torch_backend(self):
|
||||
"""Without UV_TORCH_BACKEND env var, no --torch-backend flag."""
|
||||
env = os.environ.copy()
|
||||
env.pop("UV_TORCH_BACKEND", None)
|
||||
with mock.patch.dict(os.environ, env, clear = True):
|
||||
cmd = self._call(("somepackage",))
|
||||
assert not any(
|
||||
a.startswith("--torch-backend") for a in cmd
|
||||
), f"--torch-backend should not appear by default, got: {cmd}"
|
||||
|
||||
def test_uv_torch_backend_auto(self):
|
||||
"""UV_TORCH_BACKEND=auto adds --torch-backend=auto."""
|
||||
with mock.patch.dict(os.environ, {"UV_TORCH_BACKEND": "auto"}):
|
||||
cmd = self._call(("somepackage",))
|
||||
assert "--torch-backend=auto" in cmd
|
||||
|
||||
def test_uv_torch_backend_cpu(self):
|
||||
"""UV_TORCH_BACKEND=cpu adds --torch-backend=cpu."""
|
||||
with mock.patch.dict(os.environ, {"UV_TORCH_BACKEND": "cpu"}):
|
||||
cmd = self._call(("somepackage",))
|
||||
assert "--torch-backend=cpu" in cmd
|
||||
|
||||
def test_uv_torch_backend_empty(self):
|
||||
"""UV_TORCH_BACKEND="" (empty string) should NOT add --torch-backend."""
|
||||
with mock.patch.dict(os.environ, {"UV_TORCH_BACKEND": ""}):
|
||||
cmd = self._call(("somepackage",))
|
||||
assert not any(
|
||||
a.startswith("--torch-backend") for a in cmd
|
||||
), f"Empty UV_TORCH_BACKEND should not add flag, got: {cmd}"
|
||||
|
||||
|
||||
class TestUvSafePath:
|
||||
"""_uv_safe_path hands uv a space-free `-c`/`-r` path (issue #6503)."""
|
||||
|
||||
def test_passthrough_when_no_space(self):
|
||||
"""A path without a space is returned unchanged on every platform."""
|
||||
p = "/tmp/plain/constraints.txt"
|
||||
assert ips._uv_safe_path(p) == p
|
||||
|
||||
@pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback")
|
||||
def test_posix_space_path_returns_spacefree_copy(self, tmp_path):
|
||||
src = tmp_path / "Open Source" / "constraints.txt"
|
||||
src.parent.mkdir(parents = True)
|
||||
src.write_text("torch>=2.6\n")
|
||||
|
||||
out = ips._uv_safe_path(str(src))
|
||||
|
||||
assert " " not in out, f"uv-safe path still has a space: {out!r}"
|
||||
assert out != str(src)
|
||||
assert Path(out).read_text() == "torch>=2.6\n"
|
||||
|
||||
@pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback")
|
||||
def test_posix_missing_file_falls_back_to_original(self):
|
||||
"""No file to copy -> return the original path rather than raise."""
|
||||
p = "/nonexistent dir/constraints.txt"
|
||||
assert ips._uv_safe_path(p) == p
|
||||
|
||||
|
||||
class TestUvSafePathHardening:
|
||||
"""Edge cases for uv_safe_path + the UV_OVERRIDE channel (issue #6503)."""
|
||||
|
||||
@pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback")
|
||||
def test_tmpdir_with_space_falls_back(self, tmp_path, monkeypatch):
|
||||
"""A space in the temp root itself -> fall back to the original path."""
|
||||
from backend.utils import uv_path_safety as uvps
|
||||
|
||||
spaced = tmp_path / "tmp dir with space"
|
||||
spaced.mkdir()
|
||||
monkeypatch.setattr(uvps.tempfile, "mkdtemp", lambda *a, **k: str(spaced))
|
||||
src = tmp_path / "Open Source" / "constraints.txt"
|
||||
src.parent.mkdir(parents = True)
|
||||
src.write_text("idna\n")
|
||||
assert uvps.uv_safe_path(str(src)) == str(src)
|
||||
|
||||
@pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback")
|
||||
def test_no_temp_dir_leak_on_copy_failure(self, tmp_path, monkeypatch):
|
||||
"""A copyfile failure after mkdtemp must not orphan the temp dir."""
|
||||
from backend.utils import uv_path_safety as uvps
|
||||
|
||||
src = tmp_path / "Open Source" / "constraints.txt"
|
||||
src.parent.mkdir(parents = True)
|
||||
src.write_text("idna\n")
|
||||
pattern = os.path.join(tempfile.gettempdir(), "unsloth_uv_*")
|
||||
before = set(glob.glob(pattern))
|
||||
|
||||
def boom(*a, **k):
|
||||
raise OSError("boom")
|
||||
|
||||
monkeypatch.setattr(uvps.shutil, "copyfile", boom)
|
||||
out = uvps.uv_safe_path(str(src))
|
||||
|
||||
assert out == str(src)
|
||||
assert set(glob.glob(pattern)) == before
|
||||
|
||||
@pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback")
|
||||
def test_cleanup_removes_and_clears_registry(self, tmp_path):
|
||||
"""The atexit-registered cleanup removes the copies and empties the list."""
|
||||
from backend.utils import uv_path_safety as uvps
|
||||
|
||||
src = tmp_path / "Open Source" / "constraints.txt"
|
||||
src.parent.mkdir(parents = True)
|
||||
src.write_text("idna\n")
|
||||
out = uvps.uv_safe_path(str(src))
|
||||
tmp_dir = Path(out).parent
|
||||
assert tmp_dir.is_dir() and str(tmp_dir) in uvps._UV_SAFE_PATH_TMPDIRS
|
||||
|
||||
uvps._cleanup_uv_safe_path_tmpdirs()
|
||||
|
||||
assert not tmp_dir.exists()
|
||||
assert uvps._UV_SAFE_PATH_TMPDIRS == []
|
||||
|
||||
@pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback")
|
||||
def test_uv_override_value_is_space_safe(self, tmp_path):
|
||||
"""The value stored for UV_OVERRIDE must be space-free."""
|
||||
from backend.utils import uv_path_safety as uvps
|
||||
|
||||
overrides = tmp_path / "Open Source" / "overrides-darwin-arm64.txt"
|
||||
overrides.parent.mkdir(parents = True)
|
||||
overrides.write_text("transformers>=4.57.6\n")
|
||||
|
||||
value = uvps.uv_safe_path(overrides)
|
||||
|
||||
assert " " not in value
|
||||
assert Path(value).read_text() == "transformers>=4.57.6\n"
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Run the install.sh UV_OVERRIDE space-safety shell test (issue #6503) under
|
||||
pytest, so the auto-discovered CPU test job executes it. The dedicated
|
||||
`Shell installer tests` CI job runs a fixed script list that this is not part
|
||||
of, so without this wrapper the regression would only be covered locally via
|
||||
tests/run_all.sh.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SHELL_TEST = REPO_ROOT / "tests" / "sh" / "test_install_uv_override_space.sh"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX shell installer test")
|
||||
@pytest.mark.skipif(shutil.which("bash") is None, reason = "bash not available")
|
||||
def test_install_uv_override_space_shell():
|
||||
assert SHELL_TEST.is_file(), f"missing shell test: {SHELL_TEST}"
|
||||
proc = subprocess.run(
|
||||
["bash", str(SHELL_TEST)],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stdout + proc.stderr
|
||||
assert "ALL PASSED" in proc.stdout, proc.stdout + proc.stderr
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,693 @@
|
||||
"""Tests for install_python_stack NO_TORCH / IS_MACOS requirement filtering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
# Add the studio directory so install_python_stack is importable.
|
||||
STUDIO_DIR = Path(__file__).resolve().parents[2] / "studio"
|
||||
sys.path.insert(0, str(STUDIO_DIR))
|
||||
|
||||
import install_python_stack as ips
|
||||
|
||||
# Paths to the REAL requirements files
|
||||
REQ_ROOT = Path(__file__).resolve().parents[2] / "studio" / "backend" / "requirements"
|
||||
EXTRAS_TXT = REQ_ROOT / "extras.txt"
|
||||
EXTRAS_NO_DEPS_TXT = REQ_ROOT / "extras-no-deps.txt"
|
||||
OVERRIDES_TXT = REQ_ROOT / "overrides.txt"
|
||||
TRITON_KERNELS_TXT = REQ_ROOT / "triton-kernels.txt"
|
||||
|
||||
|
||||
# ── _filter_requirements unit tests (synthetic) ───────────────────────
|
||||
|
||||
|
||||
class TestFilterRequirements:
|
||||
"""Verify _filter_requirements correctly removes packages by prefix."""
|
||||
|
||||
def _write_req(self, tmp_path: Path, content: str) -> Path:
|
||||
req = tmp_path / "requirements.txt"
|
||||
req.write_text(textwrap.dedent(content), encoding = "utf-8")
|
||||
return req
|
||||
|
||||
def test_filters_no_torch_packages(self, tmp_path):
|
||||
req = self._write_req(
|
||||
tmp_path,
|
||||
"""\
|
||||
torch-stoi==0.1
|
||||
timm>=1.0
|
||||
numpy
|
||||
torchcodec>=0.1
|
||||
torch-c-dlpack-ext
|
||||
""",
|
||||
)
|
||||
result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES)
|
||||
lines = Path(result).read_text(encoding = "utf-8").splitlines()
|
||||
non_blank = [l.strip() for l in lines if l.strip()]
|
||||
assert non_blank == ["numpy"], f"Expected only numpy, got: {non_blank}"
|
||||
|
||||
def test_empty_file(self, tmp_path):
|
||||
req = self._write_req(tmp_path, "")
|
||||
result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES)
|
||||
content = Path(result).read_text(encoding = "utf-8")
|
||||
assert content.strip() == ""
|
||||
|
||||
def test_comments_preserved(self, tmp_path):
|
||||
req = self._write_req(
|
||||
tmp_path,
|
||||
"""\
|
||||
# torch-stoi is needed for audio
|
||||
numpy
|
||||
""",
|
||||
)
|
||||
result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES)
|
||||
lines = Path(result).read_text(encoding = "utf-8").splitlines()
|
||||
non_blank = [l.strip() for l in lines if l.strip()]
|
||||
# Comment lines start with "#", so they are preserved.
|
||||
assert len(non_blank) == 2
|
||||
assert non_blank[0].startswith("#")
|
||||
assert non_blank[1] == "numpy"
|
||||
|
||||
def test_version_specifiers_filtered(self, tmp_path):
|
||||
req = self._write_req(
|
||||
tmp_path,
|
||||
"""\
|
||||
torch-stoi>=0.1.0
|
||||
timm==1.2.3
|
||||
""",
|
||||
)
|
||||
result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES)
|
||||
lines = Path(result).read_text(encoding = "utf-8").splitlines()
|
||||
non_blank = [l.strip() for l in lines if l.strip()]
|
||||
assert non_blank == [], f"Expected empty, got: {non_blank}"
|
||||
|
||||
def test_prefix_match_catches_extensions(self, tmp_path):
|
||||
"""Prefix matching catches torch-stoi-extra (correct for pip names)."""
|
||||
req = self._write_req(
|
||||
tmp_path,
|
||||
"""\
|
||||
torch-stoi-extra
|
||||
numpy
|
||||
""",
|
||||
)
|
||||
result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES)
|
||||
lines = Path(result).read_text(encoding = "utf-8").splitlines()
|
||||
non_blank = [l.strip() for l in lines if l.strip()]
|
||||
assert non_blank == ["numpy"]
|
||||
|
||||
def test_mixed_case_filtered(self, tmp_path):
|
||||
"""Package names are lowercased before matching."""
|
||||
req = self._write_req(
|
||||
tmp_path,
|
||||
"""\
|
||||
Timm>=1.0
|
||||
TORCH-STOI
|
||||
numpy
|
||||
""",
|
||||
)
|
||||
result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES)
|
||||
lines = Path(result).read_text(encoding = "utf-8").splitlines()
|
||||
non_blank = [l.strip() for l in lines if l.strip()]
|
||||
assert non_blank == ["numpy"]
|
||||
|
||||
def test_whitespace_and_blank_lines_preserved(self, tmp_path):
|
||||
req = self._write_req(
|
||||
tmp_path,
|
||||
"""\
|
||||
numpy
|
||||
|
||||
pandas
|
||||
|
||||
""",
|
||||
)
|
||||
result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES)
|
||||
content = Path(result).read_text(encoding = "utf-8")
|
||||
# Blank lines must be preserved.
|
||||
assert "\n\n" in content or content.count("\n") >= 3
|
||||
|
||||
def test_stacked_windows_and_no_torch_filters(self, tmp_path):
|
||||
"""Both WINDOWS_SKIP_PACKAGES and NO_TORCH_SKIP_PACKAGES applied."""
|
||||
req = self._write_req(
|
||||
tmp_path,
|
||||
"""\
|
||||
triton_kernels
|
||||
torch-stoi
|
||||
timm
|
||||
numpy
|
||||
""",
|
||||
)
|
||||
# Filter Windows packages, then NO_TORCH packages.
|
||||
intermediate = ips._filter_requirements(req, ips.WINDOWS_SKIP_PACKAGES)
|
||||
result = ips._filter_requirements(Path(intermediate), ips.NO_TORCH_SKIP_PACKAGES)
|
||||
lines = Path(result).read_text(encoding = "utf-8").splitlines()
|
||||
non_blank = [l.strip() for l in lines if l.strip()]
|
||||
assert non_blank == [
|
||||
"numpy"
|
||||
], f"Expected only numpy after stacked filters, got: {non_blank}"
|
||||
|
||||
def test_vcs_url_with_skip_package_name(self, tmp_path):
|
||||
"""VCS URLs like git+https://...torch-stoi should also be filtered (startswith matches)."""
|
||||
req = self._write_req(
|
||||
tmp_path,
|
||||
"""\
|
||||
numpy
|
||||
torch-stoi @ git+https://github.com/example/torch-stoi.git
|
||||
""",
|
||||
)
|
||||
result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES)
|
||||
lines = Path(result).read_text(encoding = "utf-8").splitlines()
|
||||
non_blank = [l.strip() for l in lines if l.strip()]
|
||||
assert non_blank == ["numpy"], f"VCS URL line should be filtered, got: {non_blank}"
|
||||
|
||||
def test_env_marker_line_filtered(self, tmp_path):
|
||||
"""Package lines with env markers are still filtered by prefix."""
|
||||
req = self._write_req(
|
||||
tmp_path,
|
||||
"""\
|
||||
timm>=1.0; python_version>="3.10"
|
||||
numpy
|
||||
""",
|
||||
)
|
||||
result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES)
|
||||
lines = Path(result).read_text(encoding = "utf-8").splitlines()
|
||||
non_blank = [l.strip() for l in lines if l.strip()]
|
||||
assert non_blank == ["numpy"], f"Env marker line should be filtered, got: {non_blank}"
|
||||
|
||||
def test_git_plus_url_not_over_matched(self, tmp_path):
|
||||
"""A git+ URL whose path contains a skip package name but does NOT start with it."""
|
||||
req = self._write_req(
|
||||
tmp_path,
|
||||
"""\
|
||||
git+https://github.com/meta-pytorch/OpenEnv.git
|
||||
numpy
|
||||
""",
|
||||
)
|
||||
result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES)
|
||||
lines = Path(result).read_text(encoding = "utf-8").splitlines()
|
||||
non_blank = [l.strip() for l in lines if l.strip()]
|
||||
# git+ URL starts with no skip package, so it is preserved.
|
||||
assert len(non_blank) == 2, f"git+ URL should be preserved, got: {non_blank}"
|
||||
|
||||
|
||||
# ── Real requirements file filtering ──────────────────────────────────
|
||||
|
||||
|
||||
class TestRealRequirementsFiltering:
|
||||
"""Filter the ACTUAL extras.txt and extras-no-deps.txt with NO_TORCH_SKIP_PACKAGES."""
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _check_req_files(self):
|
||||
if not EXTRAS_TXT.is_file():
|
||||
pytest.skip("extras.txt not found in repo")
|
||||
if not EXTRAS_NO_DEPS_TXT.is_file():
|
||||
pytest.skip("extras-no-deps.txt not found in repo")
|
||||
|
||||
def _non_blank_non_comment(self, path: Path) -> list[str]:
|
||||
"""Return non-blank, non-comment lines from a requirements file."""
|
||||
lines = path.read_text(encoding = "utf-8").splitlines()
|
||||
return [l.strip() for l in lines if l.strip() and not l.strip().startswith("#")]
|
||||
|
||||
def test_extras_txt_torch_packages_removed(self):
|
||||
"""extras.txt: all NO_TORCH_SKIP_PACKAGES must be removed, everything else preserved."""
|
||||
result = ips._filter_requirements(EXTRAS_TXT, ips.NO_TORCH_SKIP_PACKAGES)
|
||||
filtered = self._non_blank_non_comment(Path(result))
|
||||
original = self._non_blank_non_comment(EXTRAS_TXT)
|
||||
|
||||
# Every NO_TORCH skip package present in extras.txt must be gone.
|
||||
for pkg in ips.NO_TORCH_SKIP_PACKAGES:
|
||||
assert not any(
|
||||
l.lower().startswith(pkg) for l in filtered
|
||||
), f"{pkg} should be removed from extras.txt"
|
||||
|
||||
# Everything else must remain.
|
||||
expected = [
|
||||
l
|
||||
for l in original
|
||||
if not any(l.strip().lower().startswith(p) for p in ips.NO_TORCH_SKIP_PACKAGES)
|
||||
]
|
||||
assert filtered == expected, (
|
||||
f"Filtered extras.txt should match expected.\n"
|
||||
f"Missing: {set(expected) - set(filtered)}\n"
|
||||
f"Extra: {set(filtered) - set(expected)}"
|
||||
)
|
||||
|
||||
def test_extras_no_deps_txt_torchcodec_and_dlpack_removed(self):
|
||||
"""extras-no-deps.txt: torchcodec and torch-c-dlpack-ext must be removed."""
|
||||
result = ips._filter_requirements(EXTRAS_NO_DEPS_TXT, ips.NO_TORCH_SKIP_PACKAGES)
|
||||
filtered = self._non_blank_non_comment(Path(result))
|
||||
original = self._non_blank_non_comment(EXTRAS_NO_DEPS_TXT)
|
||||
|
||||
for pkg in ["torchcodec", "torch-c-dlpack-ext"]:
|
||||
assert not any(
|
||||
l.lower().startswith(pkg) for l in filtered
|
||||
), f"{pkg} should be removed from extras-no-deps.txt"
|
||||
|
||||
expected = [
|
||||
l
|
||||
for l in original
|
||||
if not any(l.strip().lower().startswith(p) for p in ips.NO_TORCH_SKIP_PACKAGES)
|
||||
]
|
||||
assert filtered == expected
|
||||
|
||||
def test_extras_txt_most_packages_preserved(self):
|
||||
"""Ensure a representative set of non-torch packages survive filtering."""
|
||||
result = ips._filter_requirements(EXTRAS_TXT, ips.NO_TORCH_SKIP_PACKAGES)
|
||||
filtered_text = Path(result).read_text(encoding = "utf-8").lower()
|
||||
|
||||
must_survive = ["scikit-learn", "loguru", "tiktoken", "einops", "tabulate"]
|
||||
for pkg in must_survive:
|
||||
if pkg in EXTRAS_TXT.read_text(encoding = "utf-8").lower():
|
||||
assert pkg in filtered_text, f"{pkg} should survive NO_TORCH filtering"
|
||||
|
||||
def test_extras_no_deps_txt_trl_preserved(self):
|
||||
"""trl should survive NO_TORCH filtering in extras-no-deps.txt."""
|
||||
result = ips._filter_requirements(EXTRAS_NO_DEPS_TXT, ips.NO_TORCH_SKIP_PACKAGES)
|
||||
filtered_text = Path(result).read_text(encoding = "utf-8").lower()
|
||||
assert "trl" in filtered_text, "trl should survive NO_TORCH filtering"
|
||||
|
||||
|
||||
# ── NO_TORCH constant tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNoTorchConstant:
|
||||
"""Verify NO_TORCH is derived correctly from UNSLOTH_NO_TORCH env var."""
|
||||
|
||||
def _reimport_no_torch(self) -> bool:
|
||||
return os.environ.get("UNSLOTH_NO_TORCH", "false").lower() in ("1", "true")
|
||||
|
||||
def test_true_lowercase(self):
|
||||
with mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "true"}):
|
||||
assert self._reimport_no_torch() is True
|
||||
|
||||
def test_true_one(self):
|
||||
with mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "1"}):
|
||||
assert self._reimport_no_torch() is True
|
||||
|
||||
def test_true_uppercase(self):
|
||||
with mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "TRUE"}):
|
||||
assert self._reimport_no_torch() is True
|
||||
|
||||
def test_false_string(self):
|
||||
with mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "false"}):
|
||||
assert self._reimport_no_torch() is False
|
||||
|
||||
def test_false_zero(self):
|
||||
with mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "0"}):
|
||||
assert self._reimport_no_torch() is False
|
||||
|
||||
def test_not_set(self):
|
||||
env = os.environ.copy()
|
||||
env.pop("UNSLOTH_NO_TORCH", None)
|
||||
with mock.patch.dict(os.environ, env, clear = True):
|
||||
assert self._reimport_no_torch() is False
|
||||
|
||||
def test_infer_no_torch_on_intel_mac(self):
|
||||
"""_infer_no_torch falls back to platform detection when env var is unset."""
|
||||
env = os.environ.copy()
|
||||
env.pop("UNSLOTH_NO_TORCH", None)
|
||||
with (
|
||||
mock.patch.dict(os.environ, env, clear = True),
|
||||
mock.patch.object(ips, "IS_MAC_INTEL", True),
|
||||
):
|
||||
assert ips._infer_no_torch() is True
|
||||
|
||||
def test_infer_no_torch_respects_explicit_false_on_intel_mac(self):
|
||||
"""Explicit UNSLOTH_NO_TORCH=false overrides platform detection."""
|
||||
with (
|
||||
mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "false"}),
|
||||
mock.patch.object(ips, "IS_MAC_INTEL", True),
|
||||
):
|
||||
assert ips._infer_no_torch() is False
|
||||
|
||||
def test_infer_no_torch_linux_unset(self):
|
||||
"""On Linux with env var unset, _infer_no_torch returns False."""
|
||||
env = os.environ.copy()
|
||||
env.pop("UNSLOTH_NO_TORCH", None)
|
||||
with (
|
||||
mock.patch.dict(os.environ, env, clear = True),
|
||||
mock.patch.object(ips, "IS_MAC_INTEL", False),
|
||||
):
|
||||
assert ips._infer_no_torch() is False
|
||||
|
||||
|
||||
# ── IS_MACOS constant tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIsMacosConstant:
|
||||
"""Verify IS_MACOS detection logic."""
|
||||
|
||||
def test_is_macos_matches_platform(self):
|
||||
import sys
|
||||
expected = sys.platform == "darwin"
|
||||
assert ips.IS_MACOS is expected
|
||||
|
||||
|
||||
# ── Subprocess mock of install_python_stack() ─────────────────────────
|
||||
|
||||
|
||||
class TestInstallPythonStackSubprocessMock:
|
||||
"""Mock subprocess.run to verify which req files are used/skipped per config."""
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _check_req_files(self):
|
||||
"""Skip if requirements files are missing."""
|
||||
for f in [EXTRAS_TXT, EXTRAS_NO_DEPS_TXT, OVERRIDES_TXT]:
|
||||
if not f.is_file():
|
||||
pytest.skip(f"{f.name} not found in repo")
|
||||
|
||||
def _capture_install(
|
||||
self,
|
||||
no_torch: bool,
|
||||
is_macos: bool,
|
||||
is_windows: bool,
|
||||
*,
|
||||
skip_base: bool = True,
|
||||
):
|
||||
"""Run install_python_stack() with mocked subprocess; return joined commands."""
|
||||
captured_cmds: list[list[str]] = []
|
||||
|
||||
def mock_run(cmd, **kw):
|
||||
captured_cmds.append(list(cmd) if isinstance(cmd, (list, tuple)) else [str(cmd)])
|
||||
return subprocess.CompletedProcess(cmd, 0, b"", b"")
|
||||
|
||||
env = {"SKIP_STUDIO_BASE": "1"} if skip_base else {}
|
||||
|
||||
with (
|
||||
mock.patch.object(ips, "NO_TORCH", no_torch),
|
||||
mock.patch.object(ips, "IS_MACOS", is_macos),
|
||||
mock.patch.object(ips, "IS_WINDOWS", is_windows),
|
||||
mock.patch.object(ips, "USE_UV", True),
|
||||
mock.patch.object(ips, "UV_NEEDS_SYSTEM", False),
|
||||
mock.patch.object(ips, "VERBOSE", False),
|
||||
mock.patch.object(ips, "_ensure_flash_attn", return_value = None),
|
||||
mock.patch.object(ips, "_has_usable_nvidia_gpu", return_value = False),
|
||||
mock.patch.object(ips, "_has_rocm_gpu", return_value = False),
|
||||
mock.patch("subprocess.run", side_effect = mock_run),
|
||||
mock.patch.object(ips, "_bootstrap_uv", return_value = True),
|
||||
mock.patch.object(ips, "LOCAL_DD_UNSTRUCTURED_PLUGIN", Path("/fake/plugin")),
|
||||
mock.patch("pathlib.Path.is_dir", return_value = True),
|
||||
mock.patch("pathlib.Path.is_file", return_value = True),
|
||||
):
|
||||
with mock.patch.dict(os.environ, env, clear = False):
|
||||
ips.install_python_stack()
|
||||
|
||||
return [" ".join(str(c) for c in cmd) for cmd in captured_cmds]
|
||||
|
||||
def _cmds_contain_file(self, cmds: list[str], filename: str) -> bool:
|
||||
"""Check if any captured command references the given filename."""
|
||||
return any(filename in cmd for cmd in cmds)
|
||||
|
||||
# -- NO_TORCH=True, IS_MACOS=True (Intel Mac scenario) --
|
||||
|
||||
def test_no_torch_macos_skips_overrides(self):
|
||||
"""With NO_TORCH=True, overrides.txt pip_install must NOT be called."""
|
||||
cmds = self._capture_install(no_torch = True, is_macos = True, is_windows = False)
|
||||
assert not self._cmds_contain_file(
|
||||
cmds, "overrides.txt"
|
||||
), "overrides.txt should be skipped when NO_TORCH=True"
|
||||
|
||||
def test_no_torch_macos_skips_triton(self):
|
||||
"""With IS_MACOS=True, triton-kernels.txt must NOT be called."""
|
||||
cmds = self._capture_install(no_torch = True, is_macos = True, is_windows = False)
|
||||
assert not self._cmds_contain_file(
|
||||
cmds, "triton-kernels.txt"
|
||||
), "triton-kernels.txt should be skipped on macOS"
|
||||
|
||||
def test_no_torch_macos_extras_called(self):
|
||||
"""With NO_TORCH=True, extras.txt is still called (but filtered)."""
|
||||
cmds = self._capture_install(no_torch = True, is_macos = True, is_windows = False)
|
||||
has_extras = self._cmds_contain_file(cmds, "extras.txt") or any(
|
||||
"-r" in cmd and "tmp" in cmd.lower() for cmd in cmds
|
||||
)
|
||||
assert has_extras, "extras.txt (or its filtered temp) should be called"
|
||||
|
||||
def test_no_torch_macos_extras_no_deps_called(self):
|
||||
"""With NO_TORCH=True, extras-no-deps.txt is still called (but filtered)."""
|
||||
cmds = self._capture_install(no_torch = True, is_macos = True, is_windows = False)
|
||||
has_extras_nd = self._cmds_contain_file(cmds, "extras-no-deps.txt") or any(
|
||||
"-r" in cmd and "tmp" in cmd.lower() for cmd in cmds
|
||||
)
|
||||
assert has_extras_nd, "extras-no-deps.txt (or its filtered temp) should be called"
|
||||
|
||||
# -- IS_WINDOWS=True + NO_TORCH=True (stacked) --
|
||||
|
||||
def test_windows_no_torch_skips_overrides(self):
|
||||
"""Windows+NO_TORCH: overrides.txt must be skipped."""
|
||||
cmds = self._capture_install(no_torch = True, is_macos = False, is_windows = True)
|
||||
assert not self._cmds_contain_file(
|
||||
cmds, "overrides.txt"
|
||||
), "overrides.txt should be skipped with NO_TORCH=True on Windows"
|
||||
|
||||
def test_windows_no_torch_skips_triton(self):
|
||||
"""Windows: triton-kernels.txt must be skipped (IS_WINDOWS guard)."""
|
||||
cmds = self._capture_install(no_torch = True, is_macos = False, is_windows = True)
|
||||
assert not self._cmds_contain_file(
|
||||
cmds, "triton-kernels.txt"
|
||||
), "triton-kernels.txt should be skipped on Windows"
|
||||
|
||||
# -- Normal Linux path (NO_TORCH=False, IS_MACOS=False, IS_WINDOWS=False) --
|
||||
|
||||
def test_normal_linux_includes_overrides(self):
|
||||
"""Normal Linux: torchao override step runs (via --reinstall, not overrides.txt)."""
|
||||
cmds = self._capture_install(no_torch = False, is_macos = False, is_windows = False)
|
||||
assert any(
|
||||
"--reinstall" in cmd for cmd in cmds
|
||||
), "torchao override step (--reinstall) should be called on normal Linux"
|
||||
|
||||
def test_normal_linux_includes_triton(self):
|
||||
"""Normal Linux: triton-kernels.txt IS called."""
|
||||
cmds = self._capture_install(no_torch = False, is_macos = False, is_windows = False)
|
||||
assert self._cmds_contain_file(
|
||||
cmds, "triton-kernels.txt"
|
||||
), "triton-kernels.txt should be called on normal Linux"
|
||||
|
||||
def test_normal_linux_includes_extras(self):
|
||||
"""Normal Linux: extras.txt IS called (no filtering)."""
|
||||
cmds = self._capture_install(no_torch = False, is_macos = False, is_windows = False)
|
||||
assert self._cmds_contain_file(
|
||||
cmds, "extras.txt"
|
||||
), "extras.txt should be called on normal Linux"
|
||||
|
||||
def test_normal_linux_includes_extras_no_deps(self):
|
||||
"""Normal Linux: extras-no-deps.txt IS called (no filtering)."""
|
||||
cmds = self._capture_install(no_torch = False, is_macos = False, is_windows = False)
|
||||
assert self._cmds_contain_file(
|
||||
cmds, "extras-no-deps.txt"
|
||||
), "extras-no-deps.txt should be called on normal Linux"
|
||||
|
||||
# -- Windows-only (NO_TORCH=False) to verify triton is still skipped --
|
||||
|
||||
def test_windows_only_skips_triton(self):
|
||||
"""Windows (without NO_TORCH): triton still skipped."""
|
||||
cmds = self._capture_install(no_torch = False, is_macos = False, is_windows = True)
|
||||
assert not self._cmds_contain_file(
|
||||
cmds, "triton-kernels.txt"
|
||||
), "triton-kernels.txt should be skipped on Windows even without NO_TORCH"
|
||||
|
||||
def test_windows_only_includes_overrides(self):
|
||||
"""Windows (no NO_TORCH): overrides runs via filtered temp file (check --reinstall)."""
|
||||
cmds = self._capture_install(no_torch = False, is_macos = False, is_windows = True)
|
||||
assert any(
|
||||
"--reinstall" in cmd for cmd in cmds
|
||||
), "overrides step (--reinstall) should be called on Windows when NO_TORCH=False"
|
||||
|
||||
# -- Update path (skip_base=False) to verify no-torch mode is durable --
|
||||
|
||||
def test_update_path_intel_macos_still_skips_overrides(self):
|
||||
"""Update path (no SKIP_STUDIO_BASE): overrides still skipped on Intel Mac."""
|
||||
cmds = self._capture_install(
|
||||
no_torch = True, is_macos = True, is_windows = False, skip_base = False
|
||||
)
|
||||
assert not self._cmds_contain_file(
|
||||
cmds, "overrides.txt"
|
||||
), "overrides.txt should be skipped on Intel Mac even via studio update"
|
||||
|
||||
def test_update_path_intel_macos_still_skips_triton(self):
|
||||
"""Update path (no SKIP_STUDIO_BASE): triton still skipped on macOS."""
|
||||
cmds = self._capture_install(
|
||||
no_torch = True, is_macos = True, is_windows = False, skip_base = False
|
||||
)
|
||||
assert not self._cmds_contain_file(
|
||||
cmds, "triton-kernels.txt"
|
||||
), "triton-kernels.txt should be skipped on macOS even via studio update"
|
||||
|
||||
|
||||
# ── Overrides skip structural checks ─────────────────────────────────
|
||||
|
||||
|
||||
class TestOverridesSkip:
|
||||
"""Verify overrides.txt is skipped when NO_TORCH is True (source-level check)."""
|
||||
|
||||
def test_no_torch_guard_exists_in_source(self):
|
||||
"""The install_python_stack source must contain a NO_TORCH guard around overrides."""
|
||||
source = Path(ips.__file__).read_text(encoding = "utf-8")
|
||||
assert "if NO_TORCH:" in source, "NO_TORCH guard not found in install_python_stack.py"
|
||||
|
||||
def test_overrides_skipped_when_no_torch(self):
|
||||
"""With NO_TORCH=True on the module, pip_install should NOT be called for overrides."""
|
||||
source = Path(ips.__file__).read_text(encoding = "utf-8")
|
||||
overrides_match = re.search(r"if NO_TORCH:.*?overrides", source, re.DOTALL)
|
||||
assert overrides_match is not None, "Expected NO_TORCH conditional before overrides install"
|
||||
|
||||
|
||||
# ── install.sh --no-torch flag tests ──────────────────────────────────
|
||||
|
||||
|
||||
class TestInstallShNoTorchFlag:
|
||||
"""Verify install.sh has the --no-torch flag and SKIP_TORCH variable."""
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _check_install_sh(self):
|
||||
install_sh = Path(__file__).resolve().parents[2] / "install.sh"
|
||||
if not install_sh.is_file():
|
||||
pytest.skip("install.sh not found")
|
||||
self.install_sh = install_sh
|
||||
self.source = install_sh.read_text(encoding = "utf-8")
|
||||
|
||||
def test_no_torch_flag_in_case_statement(self):
|
||||
"""--no-torch must appear in the flag parser case statement."""
|
||||
assert "--no-torch)" in self.source, "--no-torch not found in install.sh flag parser"
|
||||
|
||||
def test_no_torch_flag_variable_initialized(self):
|
||||
"""_NO_TORCH_FLAG must be initialized to false."""
|
||||
assert "_NO_TORCH_FLAG=false" in self.source, "_NO_TORCH_FLAG=false not found in install.sh"
|
||||
|
||||
def test_skip_torch_variable_exists(self):
|
||||
"""SKIP_TORCH variable must be defined."""
|
||||
assert "SKIP_TORCH=false" in self.source, "SKIP_TORCH=false not found in install.sh"
|
||||
assert "SKIP_TORCH=true" in self.source, "SKIP_TORCH=true not found in install.sh"
|
||||
|
||||
def test_skip_torch_driven_by_flag_and_mac_intel(self):
|
||||
"""SKIP_TORCH must check both _NO_TORCH_FLAG and MAC_INTEL."""
|
||||
assert "_NO_TORCH_FLAG" in self.source, "_NO_TORCH_FLAG not referenced in SKIP_TORCH logic"
|
||||
assert "MAC_INTEL" in self.source, "MAC_INTEL not referenced in SKIP_TORCH logic"
|
||||
|
||||
def test_unsloth_no_torch_uses_skip_torch(self):
|
||||
"""UNSLOTH_NO_TORCH must reference $SKIP_TORCH, not $MAC_INTEL."""
|
||||
import re
|
||||
|
||||
matches = re.findall(r'UNSLOTH_NO_TORCH="\$(\w+)"', self.source)
|
||||
for var in matches:
|
||||
assert var == "SKIP_TORCH", f"UNSLOTH_NO_TORCH references ${var} instead of $SKIP_TORCH"
|
||||
|
||||
def test_cpu_hint_message_exists(self):
|
||||
"""CPU hint message must exist in install.sh."""
|
||||
assert "No GPU detected" in self.source, "CPU hint message not found in install.sh"
|
||||
assert "--no-torch" in self.source, "--no-torch suggestion not found in CPU hint"
|
||||
|
||||
def test_no_torch_flag_parsing_subprocess(self):
|
||||
"""--no-torch flag sets _NO_TORCH_FLAG=true (subprocess test)."""
|
||||
script = textwrap.dedent("""\
|
||||
_NO_TORCH_FLAG=false
|
||||
_next_is_package=false
|
||||
STUDIO_LOCAL_INSTALL=false
|
||||
PACKAGE_NAME="unsloth"
|
||||
for arg in "$@"; do
|
||||
if [ "$_next_is_package" = true ]; then
|
||||
PACKAGE_NAME="$arg"
|
||||
_next_is_package=false
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--local) STUDIO_LOCAL_INSTALL=true ;;
|
||||
--package) _next_is_package=true ;;
|
||||
--no-torch) _NO_TORCH_FLAG=true ;;
|
||||
esac
|
||||
done
|
||||
echo "$_NO_TORCH_FLAG"
|
||||
""")
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script, "_", "--no-torch"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
)
|
||||
assert (
|
||||
result.stdout.strip() == "true"
|
||||
), f"Expected _NO_TORCH_FLAG=true, got: {result.stdout.strip()}"
|
||||
|
||||
def test_no_torch_with_local_flag(self):
|
||||
"""--no-torch and --local can be used together."""
|
||||
script = textwrap.dedent("""\
|
||||
_NO_TORCH_FLAG=false
|
||||
_next_is_package=false
|
||||
STUDIO_LOCAL_INSTALL=false
|
||||
PACKAGE_NAME="unsloth"
|
||||
for arg in "$@"; do
|
||||
if [ "$_next_is_package" = true ]; then
|
||||
PACKAGE_NAME="$arg"
|
||||
_next_is_package=false
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--local) STUDIO_LOCAL_INSTALL=true ;;
|
||||
--package) _next_is_package=true ;;
|
||||
--no-torch) _NO_TORCH_FLAG=true ;;
|
||||
esac
|
||||
done
|
||||
echo "$_NO_TORCH_FLAG $STUDIO_LOCAL_INSTALL"
|
||||
""")
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script, "_", "--local", "--no-torch"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
)
|
||||
assert (
|
||||
result.stdout.strip() == "true true"
|
||||
), f"Expected 'true true', got: {result.stdout.strip()}"
|
||||
|
||||
def test_cpu_hint_only_when_not_skip_torch(self):
|
||||
"""CPU hint should only print when SKIP_TORCH=false and OS!=macos."""
|
||||
script = textwrap.dedent("""\
|
||||
TORCH_INDEX_URL="https://download.pytorch.org/whl/cpu"
|
||||
SKIP_TORCH=false
|
||||
OS="linux"
|
||||
case "$TORCH_INDEX_URL" in
|
||||
*/cpu)
|
||||
if [ "$SKIP_TORCH" = false ] && [ "$OS" != "macos" ]; then
|
||||
echo "HINT_PRINTED"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
""")
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
)
|
||||
assert "HINT_PRINTED" in result.stdout, "CPU hint should print"
|
||||
|
||||
# With SKIP_TORCH=true, hint should NOT print
|
||||
script2 = script.replace("SKIP_TORCH=false", "SKIP_TORCH=true")
|
||||
result2 = subprocess.run(
|
||||
["bash", "-c", script2],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
)
|
||||
assert (
|
||||
"HINT_PRINTED" not in result2.stdout
|
||||
), "CPU hint should NOT print when SKIP_TORCH=true"
|
||||
|
||||
|
||||
# ── Triton macOS skip structural checks ──────────────────────────────
|
||||
|
||||
|
||||
class TestTritonMacosSkip:
|
||||
"""Verify triton is skipped on macOS (source-level check)."""
|
||||
|
||||
def test_triton_guard_in_source(self):
|
||||
"""Source must skip triton on both Windows and macOS."""
|
||||
source = Path(ips.__file__).read_text(encoding = "utf-8")
|
||||
assert (
|
||||
"not IS_MACOS" in source
|
||||
), "IS_MACOS guard for triton not found in install_python_stack.py"
|
||||
assert (
|
||||
"not IS_WINDOWS and not IS_MACOS" in source
|
||||
), "Expected 'not IS_WINDOWS and not IS_MACOS' guard for triton"
|
||||
@@ -0,0 +1,246 @@
|
||||
"""ORPO should use a processor's tokenizer for text-only row tokenization."""
|
||||
|
||||
import ast
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
RL_PATH = os.path.join(REPO_ROOT, "unsloth", "models", "rl_replacements.py")
|
||||
|
||||
|
||||
def _load_orpo_rewriter(name = "orpo_trainer_text_tokenizer"):
|
||||
src = open(RL_PATH).read()
|
||||
tree = ast.parse(src)
|
||||
ns = {"re": re}
|
||||
# Materialise sibling module-level _-prefixed assignments the rewriter may reference.
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id.startswith("_"):
|
||||
exec(ast.get_source_segment(src, node), ns)
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||||
exec(ast.get_source_segment(src, node), ns)
|
||||
return ns[name]
|
||||
raise AssertionError(f"{name} not found")
|
||||
|
||||
|
||||
class _Tokenizer:
|
||||
bos_token_id = 1
|
||||
eos_token_id = 2
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
text,
|
||||
add_special_tokens = False,
|
||||
**kwargs,
|
||||
):
|
||||
self.calls.append((text, add_special_tokens, kwargs))
|
||||
ids = [ord(c) % 31 + 3 for c in text]
|
||||
return {"input_ids": ids, "attention_mask": [1] * len(ids)}
|
||||
|
||||
|
||||
class _Processor:
|
||||
def __init__(self):
|
||||
self.tokenizer = _Tokenizer()
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
raise AssertionError("text-only ORPO tokenization should not call processor")
|
||||
|
||||
|
||||
class _Trainer:
|
||||
def __init__(self):
|
||||
self.processing_class = _Processor()
|
||||
self.is_encoder_decoder = False
|
||||
self.max_length = 2048
|
||||
self.max_prompt_length = 1024
|
||||
self.max_completion_length = 1024
|
||||
self.truncation_mode = "keep_end"
|
||||
self.label_pad_token_id = -100
|
||||
self.padding_value = 0
|
||||
|
||||
|
||||
def _exec_rewritten(
|
||||
function_name,
|
||||
source,
|
||||
extra_ns = None,
|
||||
):
|
||||
rewriter = _load_orpo_rewriter()
|
||||
rewritten = rewriter(function_name, source)
|
||||
ns = {} if extra_ns is None else dict(extra_ns)
|
||||
exec(rewritten, ns)
|
||||
return ns[function_name]
|
||||
|
||||
|
||||
def test_orpo_tokenize_row_returns_original_when_tokenizer_anchor_missing():
|
||||
rewriter = _load_orpo_rewriter()
|
||||
source = """
|
||||
def tokenize_row(self, feature, model=None):
|
||||
output = {}
|
||||
output["prompt_input_ids"] = self.processing_class(feature["prompt"], add_special_tokens=False)["input_ids"]
|
||||
return output
|
||||
"""
|
||||
|
||||
rewritten = rewriter("tokenize_row", source)
|
||||
|
||||
assert rewritten == source
|
||||
assert "tokenizer(" not in rewritten
|
||||
|
||||
|
||||
def test_orpo_build_tokenized_answer_uses_processor_tokenizer():
|
||||
source = """
|
||||
def build_tokenized_answer(self, prompt, answer):
|
||||
full_tokenized = self.processing_class(prompt + answer, add_special_tokens=False)
|
||||
prompt_input_ids = self.processing_class(prompt, add_special_tokens=False)["input_ids"]
|
||||
return full_tokenized["input_ids"][len(prompt_input_ids):]
|
||||
"""
|
||||
fn = _exec_rewritten("build_tokenized_answer", source)
|
||||
trainer = _Trainer()
|
||||
|
||||
assert fn(trainer, "a", "b")
|
||||
assert [call[0] for call in trainer.processing_class.tokenizer.calls] == ["ab", "a"]
|
||||
|
||||
|
||||
def test_orpo_tokenize_row_uses_processor_tokenizer():
|
||||
source = """
|
||||
def tokenize_row(self, feature, model=None):
|
||||
batch = {}
|
||||
prompt = feature["prompt"]
|
||||
chosen = feature["chosen"]
|
||||
rejected = feature["rejected"]
|
||||
if not self.is_encoder_decoder:
|
||||
prompt_tokens = self.processing_class(prompt, add_special_tokens=False)
|
||||
prompt_tokens = {f"prompt_{k}": v for k, v in prompt_tokens.items()}
|
||||
chosen_tokens = self.build_tokenized_answer(prompt, chosen)
|
||||
rejected_tokens = self.build_tokenized_answer(prompt, rejected)
|
||||
prompt_len_input_ids = len(prompt_tokens["prompt_input_ids"])
|
||||
chosen_prompt_len_input_ids = len(chosen_tokens["prompt_input_ids"])
|
||||
rejected_prompt_len_input_ids = len(rejected_tokens["prompt_input_ids"])
|
||||
prompt_tokens, chosen_tokens, rejected_tokens = add_bos_token_if_needed(
|
||||
self.processing_class.bos_token_id,
|
||||
prompt_len_input_ids,
|
||||
prompt_tokens,
|
||||
chosen_prompt_len_input_ids,
|
||||
chosen_tokens,
|
||||
rejected_prompt_len_input_ids,
|
||||
rejected_tokens,
|
||||
)
|
||||
chosen_tokens, rejected_tokens = add_eos_token_if_needed(
|
||||
self.processing_class.eos_token_id, chosen_tokens, rejected_tokens
|
||||
)
|
||||
batch["prompt_input_ids"] = prompt_tokens["prompt_input_ids"]
|
||||
batch["chosen_input_ids"] = chosen_tokens["input_ids"]
|
||||
batch["rejected_input_ids"] = rejected_tokens["input_ids"]
|
||||
return batch
|
||||
"""
|
||||
|
||||
def add_bos_token_if_needed(*args):
|
||||
return args[2], args[4], args[6]
|
||||
|
||||
def add_eos_token_if_needed(eos_token_id, chosen_tokens, rejected_tokens):
|
||||
chosen_tokens["input_ids"] = chosen_tokens["input_ids"] + [eos_token_id]
|
||||
rejected_tokens["input_ids"] = rejected_tokens["input_ids"] + [eos_token_id]
|
||||
return chosen_tokens, rejected_tokens
|
||||
|
||||
trainer = _Trainer()
|
||||
trainer.build_tokenized_answer = lambda prompt, answer: {
|
||||
"prompt_input_ids": trainer.processing_class.tokenizer(prompt)["input_ids"],
|
||||
"input_ids": trainer.processing_class.tokenizer(answer)["input_ids"],
|
||||
}
|
||||
fn = _exec_rewritten(
|
||||
"tokenize_row",
|
||||
source,
|
||||
{
|
||||
"add_bos_token_if_needed": add_bos_token_if_needed,
|
||||
"add_eos_token_if_needed": add_eos_token_if_needed,
|
||||
},
|
||||
)
|
||||
|
||||
output = fn(trainer, {"prompt": "p", "chosen": "c", "rejected": "r"})
|
||||
|
||||
assert output["chosen_input_ids"][-1] == _Tokenizer.eos_token_id
|
||||
assert [call[0] for call in trainer.processing_class.tokenizer.calls] == [
|
||||
"p",
|
||||
"p",
|
||||
"c",
|
||||
"p",
|
||||
"r",
|
||||
]
|
||||
|
||||
|
||||
def test_orpo_init_pad_token_id_falls_back_to_tokenizer():
|
||||
rewriter = _load_orpo_rewriter("orpo_trainer_processor_pad_token")
|
||||
source = """
|
||||
def __init__(self, processing_class):
|
||||
data_collator = DPODataCollatorWithPadding(
|
||||
pad_token_id=processing_class.pad_token_id,
|
||||
)
|
||||
self.padding_value = processing_class.pad_token_id
|
||||
"""
|
||||
|
||||
rewritten = rewriter("__init__", source)
|
||||
|
||||
assert "processing_class.pad_token_id" not in rewritten
|
||||
assert "getattr(processing_class, 'pad_token_id'" in rewritten
|
||||
|
||||
class _Processor:
|
||||
# No pad_token_id at the processor level; only on the inner tokenizer.
|
||||
class tokenizer:
|
||||
pad_token_id = 17
|
||||
|
||||
captured = {}
|
||||
|
||||
def DPODataCollatorWithPadding(**kwargs):
|
||||
captured["pad_token_id"] = kwargs["pad_token_id"]
|
||||
return object()
|
||||
|
||||
ns = {"DPODataCollatorWithPadding": DPODataCollatorWithPadding}
|
||||
exec(rewritten, ns)
|
||||
|
||||
class _Trainer:
|
||||
pass
|
||||
|
||||
trainer = _Trainer()
|
||||
ns["__init__"](trainer, _Processor())
|
||||
|
||||
assert captured["pad_token_id"] == 17
|
||||
assert trainer.padding_value == 17
|
||||
|
||||
|
||||
def test_orpo_init_pad_token_id_uses_processor_when_present():
|
||||
rewriter = _load_orpo_rewriter("orpo_trainer_processor_pad_token")
|
||||
source = """
|
||||
def __init__(self, processing_class):
|
||||
self.padding_value = processing_class.pad_token_id
|
||||
"""
|
||||
|
||||
rewritten = rewriter("__init__", source)
|
||||
|
||||
class _Tokenizer:
|
||||
# Inner tokenizer must NOT be consulted when the processor exposes
|
||||
# pad_token_id itself.
|
||||
pad_token_id = 999
|
||||
|
||||
class _Processor:
|
||||
pad_token_id = 42
|
||||
tokenizer = _Tokenizer()
|
||||
|
||||
ns = {}
|
||||
exec(rewritten, ns)
|
||||
|
||||
class _Trainer:
|
||||
pass
|
||||
|
||||
trainer = _Trainer()
|
||||
ns["__init__"](trainer, _Processor())
|
||||
assert trainer.padding_value == 42
|
||||
|
||||
|
||||
def test_orpo_init_pad_token_id_noop_on_non_init():
|
||||
rewriter = _load_orpo_rewriter("orpo_trainer_processor_pad_token")
|
||||
source = "def tokenize_row(self):\n return processing_class.pad_token_id\n"
|
||||
assert rewriter("tokenize_row", source) == source
|
||||
@@ -0,0 +1,109 @@
|
||||
"""_fix_pad_token dispatch in unsloth/tokenizer_utils.py.
|
||||
|
||||
It must delegate to unsloth_zoo's shared fix_pad_token when present (single
|
||||
source of truth), and fall back to a no-op against an older unsloth_zoo. Static
|
||||
+ CPU-only: _fix_pad_token is exec'd in isolation so the test never imports
|
||||
torch / transformers / unsloth.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
TOK_PATH = os.path.join(REPO_ROOT, "unsloth", "tokenizer_utils.py")
|
||||
|
||||
WANTED = {
|
||||
"_fix_pad_token",
|
||||
}
|
||||
|
||||
|
||||
def _load_pad_helpers():
|
||||
"""Exec only the pad-token helpers with a stub logger (no heavy imports)."""
|
||||
tree = ast.parse(open(TOK_PATH).read())
|
||||
nodes = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
names = {t.id for t in node.targets if isinstance(t, ast.Name)}
|
||||
if names & WANTED:
|
||||
nodes.append(node)
|
||||
elif isinstance(node, ast.FunctionDef) and node.name in WANTED:
|
||||
nodes.append(node)
|
||||
module = ast.Module(body = nodes, type_ignores = [])
|
||||
ast.fix_missing_locations(module)
|
||||
ns = {"logger": types.SimpleNamespace(warning = lambda *a, **k: None)}
|
||||
exec(compile(module, TOK_PATH, "exec"), ns)
|
||||
return ns
|
||||
|
||||
|
||||
class FakeTok:
|
||||
def __init__(self, vocab, pad, eos):
|
||||
self._v = dict(vocab)
|
||||
self.pad_token = pad
|
||||
self.eos_token = eos
|
||||
|
||||
@property
|
||||
def pad_token_id(self):
|
||||
return self._v.get(self.pad_token)
|
||||
|
||||
@property
|
||||
def eos_token_id(self):
|
||||
return self._v.get(self.eos_token)
|
||||
|
||||
def get_vocab(self):
|
||||
return dict(self._v)
|
||||
|
||||
|
||||
def _block_shared_module(monkeypatch):
|
||||
# Stub parent so importing it is cheap, and mark the submodule absent.
|
||||
monkeypatch.setitem(sys.modules, "unsloth_zoo", types.ModuleType("unsloth_zoo"))
|
||||
monkeypatch.setitem(sys.modules, "unsloth_zoo.pad_token", None)
|
||||
|
||||
|
||||
def test_fix_pad_token_none_is_noop():
|
||||
ns = _load_pad_helpers()
|
||||
assert ns["_fix_pad_token"](None) is None
|
||||
|
||||
|
||||
def test_fallback_keeps_pad_named_token(monkeypatch):
|
||||
ns = _load_pad_helpers()
|
||||
_block_shared_module(monkeypatch)
|
||||
# A pad-named token (e.g. <|vision_pad|>) is a valid pad -> fallback keeps it.
|
||||
tok = FakeTok(
|
||||
{"<|endoftext|>": 1, "<|im_end|>": 2, "<|vision_pad|>": 3},
|
||||
pad = "<|vision_pad|>",
|
||||
eos = "<|im_end|>",
|
||||
)
|
||||
ns["_fix_pad_token"](tok)
|
||||
assert tok.pad_token == "<|vision_pad|>"
|
||||
assert tok.pad_token != tok.eos_token
|
||||
|
||||
|
||||
def test_fallback_leaves_valid_pad_untouched(monkeypatch):
|
||||
ns = _load_pad_helpers()
|
||||
_block_shared_module(monkeypatch)
|
||||
tok = FakeTok({"<s>": 1, "</s>": 2, "<pad>": 0}, pad = "<pad>", eos = "</s>")
|
||||
ns["_fix_pad_token"](tok)
|
||||
assert tok.pad_token == "<pad>"
|
||||
|
||||
|
||||
def test_fix_pad_token_delegates_to_shared_module(monkeypatch):
|
||||
ns = _load_pad_helpers()
|
||||
calls = {}
|
||||
|
||||
fake = types.ModuleType("unsloth_zoo.pad_token")
|
||||
|
||||
def fix_pad_token(tokenizer, allow_add = True):
|
||||
calls["allow_add"] = allow_add
|
||||
tokenizer.pad_token = "SHARED"
|
||||
|
||||
fake.fix_pad_token = fix_pad_token
|
||||
monkeypatch.setitem(sys.modules, "unsloth_zoo", types.ModuleType("unsloth_zoo"))
|
||||
monkeypatch.setitem(sys.modules, "unsloth_zoo.pad_token", fake)
|
||||
|
||||
tok = FakeTok({"a": 1}, pad = "x", eos = "y")
|
||||
ns["_fix_pad_token"](tok)
|
||||
# Delegated, and crucially with allow_add=False (no model here to resize).
|
||||
assert tok.pad_token == "SHARED"
|
||||
assert calls["allow_add"] is False
|
||||
@@ -0,0 +1,81 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
"""Regression: _patch_trl_rl_trainers (the ring-fencing wrapper in
|
||||
unsloth/models/rl.py) must never raise."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
pytest.importorskip("trl")
|
||||
|
||||
|
||||
def _import_helpers():
|
||||
try:
|
||||
from unsloth.models.rl import (
|
||||
_patch_trl_rl_trainers,
|
||||
_patch_trl_rl_trainers_impl,
|
||||
)
|
||||
except ImportError as e:
|
||||
pytest.skip(f"unsloth.models.rl helpers not importable: {e}")
|
||||
return _patch_trl_rl_trainers, _patch_trl_rl_trainers_impl
|
||||
|
||||
|
||||
def test_patch_trl_rl_trainers_swallows_unknown_trainer_name():
|
||||
wrapper, _impl = _import_helpers()
|
||||
assert wrapper("definitely_not_a_real_trainer_xyz") is None
|
||||
|
||||
|
||||
def test_patch_trl_rl_trainers_swallows_garbage_input():
|
||||
wrapper, _impl = _import_helpers()
|
||||
for bad in ("", "..", "trainer with space", "sft_trainer; rm -rf /"):
|
||||
assert wrapper(bad) is None, f"raised on input: {bad!r}"
|
||||
|
||||
|
||||
def test_impl_is_separately_exposed():
|
||||
# The impl stays directly callable for the raising path.
|
||||
_wrapper, impl = _import_helpers()
|
||||
assert callable(impl)
|
||||
|
||||
|
||||
def test_wrapper_delegates_to_impl(monkeypatch):
|
||||
from unsloth.models import rl as _rl
|
||||
|
||||
sentinel = object()
|
||||
calls = []
|
||||
|
||||
def _fake_impl(trainer_file):
|
||||
calls.append(trainer_file)
|
||||
return sentinel
|
||||
|
||||
monkeypatch.setattr(_rl, "_patch_trl_rl_trainers_impl", _fake_impl)
|
||||
assert _rl._patch_trl_rl_trainers("sft_trainer") is sentinel
|
||||
assert calls == ["sft_trainer"]
|
||||
|
||||
|
||||
def test_wrapper_swallows_impl_exception(monkeypatch):
|
||||
from unsloth.models import rl as _rl
|
||||
|
||||
def _boom(_trainer_file):
|
||||
raise RuntimeError("simulated TRL 1.x rename failure")
|
||||
|
||||
monkeypatch.setattr(_rl, "_patch_trl_rl_trainers_impl", _boom)
|
||||
assert _rl._patch_trl_rl_trainers("sft_trainer") is None
|
||||
|
||||
|
||||
def test_grpo_config_sibling_module_import_is_patched(tmp_path):
|
||||
import unsloth # noqa: F401
|
||||
from trl import GRPOConfig as top_config
|
||||
from trl.trainer import GRPOConfig as trainer_config
|
||||
from trl.trainer.grpo_config import GRPOConfig as config_module_config
|
||||
from trl.trainer.grpo_trainer import GRPOConfig as trainer_module_config
|
||||
|
||||
assert top_config is trainer_config
|
||||
assert top_config is trainer_module_config
|
||||
assert top_config is config_module_config
|
||||
|
||||
args = config_module_config(output_dir = str(tmp_path))
|
||||
assert hasattr(args, "unsloth_grpo_mini_batch")
|
||||
assert args.unsloth_grpo_mini_batch is None
|
||||
@@ -0,0 +1,46 @@
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_remove_special_tokens():
|
||||
# Extract remove_special_tokens without importing unsloth (importing unsloth
|
||||
# needs unsloth_zoo / a GPU). The function is pure Python and uses no imports,
|
||||
# so it execs cleanly in an empty namespace.
|
||||
source = Path(__file__).parents[2] / "unsloth" / "chat_templates.py"
|
||||
tree = ast.parse(source.read_text(encoding = "utf-8"))
|
||||
funcs = [
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "remove_special_tokens"
|
||||
]
|
||||
namespace = {}
|
||||
module = ast.Module(body = funcs, type_ignores = [])
|
||||
ast.fix_missing_locations(module)
|
||||
exec(compile(module, str(source), "exec"), namespace)
|
||||
return namespace["remove_special_tokens"]
|
||||
|
||||
|
||||
class _StubTokenizer:
|
||||
def __init__(self, bos_token):
|
||||
self.bos_token = bos_token
|
||||
|
||||
|
||||
def test_no_bos_tokenizer_does_not_crash():
|
||||
# Tokenizers such as Qwen2 / Qwen2.5, GPT-2, Falcon and GPT-NeoX have no BOS
|
||||
# token, so tokenizer.bos_token is None. remove_special_tokens must leave the
|
||||
# prompt untouched instead of raising
|
||||
# "TypeError: startswith first arg must be str or a tuple of str, not NoneType".
|
||||
remove_special_tokens = _load_remove_special_tokens()
|
||||
assert remove_special_tokens(_StubTokenizer(None), "Hello world") == "Hello world"
|
||||
|
||||
|
||||
def test_double_bos_is_stripped():
|
||||
# A tokenizer with a BOS token still has a single leading BOS removed.
|
||||
remove_special_tokens = _load_remove_special_tokens()
|
||||
assert remove_special_tokens(_StubTokenizer("<s>"), "<s>Hello world") == "Hello world"
|
||||
|
||||
|
||||
def test_prompt_without_leading_bos_unchanged():
|
||||
# A BOS-bearing tokenizer leaves a prompt that does not start with BOS alone.
|
||||
remove_special_tokens = _load_remove_special_tokens()
|
||||
assert remove_special_tokens(_StubTokenizer("<s>"), "Hello world") == "Hello world"
|
||||
@@ -0,0 +1,569 @@
|
||||
"""Sandbox tests: Studio dataset modules load/run in isolated no-torch venvs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DATA_COLLATORS = REPO_ROOT / "studio" / "backend" / "utils" / "datasets" / "data_collators.py"
|
||||
CHAT_TEMPLATES = REPO_ROOT / "studio" / "backend" / "utils" / "datasets" / "chat_templates.py"
|
||||
FORMAT_CONVERSION = REPO_ROOT / "studio" / "backend" / "utils" / "datasets" / "format_conversion.py"
|
||||
|
||||
|
||||
def _has_uv() -> bool:
|
||||
return shutil.which("uv") is not None
|
||||
|
||||
|
||||
def _create_venv(venv_dir: Path, python_version: str) -> Path | None:
|
||||
"""Create a uv venv at the given Python version. Returns python path or None."""
|
||||
result = subprocess.run(
|
||||
["uv", "venv", str(venv_dir), "--python", python_version],
|
||||
capture_output = True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
venv_python = venv_dir / "bin" / "python"
|
||||
if not venv_python.exists():
|
||||
venv_python = venv_dir / "Scripts" / "python.exe"
|
||||
return venv_python if venv_python.exists() else None
|
||||
|
||||
|
||||
@pytest.fixture(params = ["3.12", "3.13"], scope = "module")
|
||||
def no_torch_venv(request, tmp_path_factory):
|
||||
"""Temp no-torch venv, parametrized for 3.12 (Intel Mac) and 3.13 (Apple Silicon / Linux)."""
|
||||
if not _has_uv():
|
||||
pytest.skip("uv not available")
|
||||
|
||||
py_version = request.param
|
||||
venv_dir = tmp_path_factory.mktemp(f"no_torch_venv_{py_version}")
|
||||
venv_python = _create_venv(venv_dir, py_version)
|
||||
if venv_python is None:
|
||||
pytest.skip(f"Could not create Python {py_version} venv")
|
||||
|
||||
check = subprocess.run(
|
||||
[str(venv_python), "-c", "import torch"],
|
||||
capture_output = True,
|
||||
)
|
||||
assert check.returncode != 0, f"torch should NOT be importable in fresh {py_version} venv"
|
||||
|
||||
return str(venv_python)
|
||||
|
||||
|
||||
# ── AST structural checks ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDataCollatorsAST:
|
||||
"""Static analysis: data_collators.py has no top-level torch imports."""
|
||||
|
||||
def test_ast_parse(self):
|
||||
"""data_collators.py must be valid Python syntax."""
|
||||
source = DATA_COLLATORS.read_text(encoding = "utf-8")
|
||||
tree = ast.parse(source, filename = str(DATA_COLLATORS))
|
||||
assert tree is not None
|
||||
|
||||
def test_no_top_level_torch_import(self):
|
||||
"""No top-level 'import torch' or 'from torch' statements."""
|
||||
source = DATA_COLLATORS.read_text(encoding = "utf-8")
|
||||
tree = ast.parse(source)
|
||||
for node in ast.iter_child_nodes(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
assert not alias.name.startswith(
|
||||
"torch"
|
||||
), f"Top-level 'import {alias.name}' found at line {node.lineno}"
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module:
|
||||
assert not node.module.startswith(
|
||||
"torch"
|
||||
), f"Top-level 'from {node.module}' found at line {node.lineno}"
|
||||
|
||||
|
||||
class TestChatTemplatesAST:
|
||||
"""Static analysis: chat_templates.py has no top-level torch imports."""
|
||||
|
||||
def test_ast_parse(self):
|
||||
"""chat_templates.py must be valid Python syntax."""
|
||||
source = CHAT_TEMPLATES.read_text(encoding = "utf-8")
|
||||
tree = ast.parse(source, filename = str(CHAT_TEMPLATES))
|
||||
assert tree is not None
|
||||
|
||||
def test_no_top_level_torch_import(self):
|
||||
"""No top-level 'import torch' or 'from torch' at module level."""
|
||||
source = CHAT_TEMPLATES.read_text(encoding = "utf-8")
|
||||
tree = ast.parse(source)
|
||||
for node in ast.iter_child_nodes(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
assert not alias.name.startswith(
|
||||
"torch"
|
||||
), f"Top-level 'import {alias.name}' found at line {node.lineno}"
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module:
|
||||
assert not node.module.startswith(
|
||||
"torch"
|
||||
), f"Top-level 'from {node.module}' found at line {node.lineno}"
|
||||
|
||||
def test_torch_imports_only_inside_functions(self):
|
||||
"""All 'from torch' imports must be inside function/method bodies."""
|
||||
source = CHAT_TEMPLATES.read_text(encoding = "utf-8")
|
||||
tree = ast.parse(source)
|
||||
torch_imports = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
module = None
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
module = node.module
|
||||
elif isinstance(node, ast.Import):
|
||||
module = node.names[0].name if node.names else None
|
||||
if module and module.startswith("torch"):
|
||||
torch_imports.append(node)
|
||||
|
||||
top_level = set(id(n) for n in ast.iter_child_nodes(tree))
|
||||
for imp in torch_imports:
|
||||
assert id(imp) not in top_level, (
|
||||
f"torch import at line {imp.lineno} is at top level"
|
||||
" (should be inside a function)"
|
||||
)
|
||||
|
||||
|
||||
# ── data_collators.py: exec + dataclass instantiation in no-torch venv ──
|
||||
|
||||
|
||||
class TestDataCollatorsNoTorchVenv:
|
||||
"""Run data_collators.py in an isolated no-torch venv, verify classes load."""
|
||||
|
||||
def test_exec_in_no_torch_venv(self, no_torch_venv):
|
||||
"""data_collators.py executes in a venv without torch (with loggers stub)."""
|
||||
code = textwrap.dedent(f"""\
|
||||
import sys, types
|
||||
loggers = types.ModuleType('loggers')
|
||||
loggers.get_logger = lambda n: None
|
||||
sys.modules['loggers'] = loggers
|
||||
exec(open({str(DATA_COLLATORS)!r}).read())
|
||||
print("OK: exec succeeded")
|
||||
""")
|
||||
result = subprocess.run(
|
||||
[no_torch_venv, "-c", code],
|
||||
capture_output = True,
|
||||
timeout = 30,
|
||||
)
|
||||
assert (
|
||||
result.returncode == 0
|
||||
), f"data_collators.py failed in no-torch venv:\n{result.stderr.decode()}"
|
||||
assert b"OK: exec succeeded" in result.stdout
|
||||
|
||||
def test_dataclass_speech_collator_instantiable(self, no_torch_venv):
|
||||
"""DataCollatorSpeechSeq2SeqWithPadding can be instantiated with processor=None."""
|
||||
code = textwrap.dedent(f"""\
|
||||
import sys, types
|
||||
loggers = types.ModuleType('loggers')
|
||||
loggers.get_logger = lambda n: None
|
||||
sys.modules['loggers'] = loggers
|
||||
exec(open({str(DATA_COLLATORS)!r}).read())
|
||||
obj = DataCollatorSpeechSeq2SeqWithPadding(processor=None)
|
||||
assert obj.processor is None, "processor should be None"
|
||||
print("OK: DataCollatorSpeechSeq2SeqWithPadding instantiated")
|
||||
""")
|
||||
result = subprocess.run(
|
||||
[no_torch_venv, "-c", code],
|
||||
capture_output = True,
|
||||
timeout = 30,
|
||||
)
|
||||
assert (
|
||||
result.returncode == 0
|
||||
), f"DataCollatorSpeechSeq2SeqWithPadding failed:\n{result.stderr.decode()}"
|
||||
assert b"OK: DataCollatorSpeechSeq2SeqWithPadding instantiated" in result.stdout
|
||||
|
||||
def test_dataclass_deepseek_collator_instantiable(self, no_torch_venv):
|
||||
"""DeepSeekOCRDataCollator can be instantiated with processor=None."""
|
||||
code = textwrap.dedent(f"""\
|
||||
import sys, types
|
||||
loggers = types.ModuleType('loggers')
|
||||
loggers.get_logger = lambda n: None
|
||||
sys.modules['loggers'] = loggers
|
||||
exec(open({str(DATA_COLLATORS)!r}).read())
|
||||
obj = DeepSeekOCRDataCollator(processor=None)
|
||||
assert obj.processor is None, "processor should be None"
|
||||
assert obj.max_length == 2048, "default max_length should be 2048"
|
||||
assert obj.ignore_index == -100, "default ignore_index should be -100"
|
||||
print("OK: DeepSeekOCRDataCollator instantiated")
|
||||
""")
|
||||
result = subprocess.run(
|
||||
[no_torch_venv, "-c", code],
|
||||
capture_output = True,
|
||||
timeout = 30,
|
||||
)
|
||||
assert result.returncode == 0, f"DeepSeekOCRDataCollator failed:\n{result.stderr.decode()}"
|
||||
assert b"OK: DeepSeekOCRDataCollator instantiated" in result.stdout
|
||||
|
||||
def test_dataclass_vlm_collator_instantiable(self, no_torch_venv):
|
||||
"""VLMDataCollator can be instantiated with processor=None."""
|
||||
code = textwrap.dedent(f"""\
|
||||
import sys, types
|
||||
loggers = types.ModuleType('loggers')
|
||||
loggers.get_logger = lambda n: None
|
||||
sys.modules['loggers'] = loggers
|
||||
exec(open({str(DATA_COLLATORS)!r}).read())
|
||||
obj = VLMDataCollator(processor=None)
|
||||
assert obj.processor is None
|
||||
assert obj.mask_input_tokens is True, "default mask_input_tokens should be True"
|
||||
print("OK: VLMDataCollator instantiated")
|
||||
""")
|
||||
result = subprocess.run(
|
||||
[no_torch_venv, "-c", code],
|
||||
capture_output = True,
|
||||
timeout = 30,
|
||||
)
|
||||
assert result.returncode == 0, f"VLMDataCollator failed:\n{result.stderr.decode()}"
|
||||
assert b"OK: VLMDataCollator instantiated" in result.stdout
|
||||
|
||||
|
||||
# ── chat_templates.py: exec in no-torch venv ─────────────────────────
|
||||
|
||||
|
||||
class TestChatTemplatesNoTorchVenv:
|
||||
"""Run chat_templates.py in an isolated no-torch venv with stubs."""
|
||||
|
||||
def test_exec_with_stubs(self, no_torch_venv):
|
||||
"""chat_templates.py top-level exec works with stubs for relative imports."""
|
||||
code = textwrap.dedent(f"""\
|
||||
import sys, types
|
||||
|
||||
# Stub loggers
|
||||
loggers = types.ModuleType('loggers')
|
||||
loggers.get_logger = lambda n: type('L', (), {{'info': lambda s, m: None, 'warning': lambda s, m: None, 'debug': lambda s, m: None}})()
|
||||
sys.modules['loggers'] = loggers
|
||||
|
||||
# Stub relative imports (.format_detection, .model_mappings)
|
||||
format_detection = types.ModuleType('format_detection')
|
||||
format_detection.detect_dataset_format = lambda *a, **k: None
|
||||
format_detection.detect_multimodal_dataset = lambda *a, **k: None
|
||||
format_detection.detect_custom_format_heuristic = lambda *a, **k: None
|
||||
sys.modules['format_detection'] = format_detection
|
||||
|
||||
model_mappings = types.ModuleType('model_mappings')
|
||||
model_mappings.MODEL_TO_TEMPLATE_MAPPER = {{}}
|
||||
sys.modules['model_mappings'] = model_mappings
|
||||
|
||||
iterable = types.ModuleType('iterable')
|
||||
iterable.is_streaming_dataset = lambda *a, **k: False
|
||||
sys.modules['iterable'] = iterable
|
||||
|
||||
# Read and transform the source: replace relative imports with absolute
|
||||
source = open({str(CHAT_TEMPLATES)!r}).read()
|
||||
source = source.replace('from .format_detection import', 'from format_detection import')
|
||||
source = source.replace('from .model_mappings import', 'from model_mappings import')
|
||||
source = source.replace('from .iterable import', 'from iterable import')
|
||||
|
||||
exec(source)
|
||||
|
||||
# Verify module-level constants are defined
|
||||
ns = dict(locals())
|
||||
assert 'DEFAULT_ALPACA_TEMPLATE' in ns, "DEFAULT_ALPACA_TEMPLATE not defined after exec"
|
||||
print("OK: chat_templates.py exec succeeded")
|
||||
""")
|
||||
result = subprocess.run(
|
||||
[no_torch_venv, "-c", code],
|
||||
capture_output = True,
|
||||
timeout = 30,
|
||||
)
|
||||
assert (
|
||||
result.returncode == 0
|
||||
), f"chat_templates.py failed in no-torch venv:\n{result.stderr.decode()}"
|
||||
assert b"OK: chat_templates.py exec succeeded" in result.stdout
|
||||
|
||||
def test_default_alpaca_template_defined(self, no_torch_venv):
|
||||
"""DEFAULT_ALPACA_TEMPLATE constant is accessible after exec."""
|
||||
code = textwrap.dedent(f"""\
|
||||
import sys, types
|
||||
|
||||
loggers = types.ModuleType('loggers')
|
||||
loggers.get_logger = lambda n: type('L', (), {{'info': lambda s, m: None, 'warning': lambda s, m: None, 'debug': lambda s, m: None}})()
|
||||
sys.modules['loggers'] = loggers
|
||||
|
||||
format_detection = types.ModuleType('format_detection')
|
||||
format_detection.detect_dataset_format = lambda *a, **k: None
|
||||
format_detection.detect_multimodal_dataset = lambda *a, **k: None
|
||||
format_detection.detect_custom_format_heuristic = lambda *a, **k: None
|
||||
sys.modules['format_detection'] = format_detection
|
||||
|
||||
model_mappings = types.ModuleType('model_mappings')
|
||||
model_mappings.MODEL_TO_TEMPLATE_MAPPER = {{}}
|
||||
sys.modules['model_mappings'] = model_mappings
|
||||
|
||||
iterable = types.ModuleType('iterable')
|
||||
iterable.is_streaming_dataset = lambda *a, **k: False
|
||||
sys.modules['iterable'] = iterable
|
||||
|
||||
ns = {{}}
|
||||
source = open({str(CHAT_TEMPLATES)!r}).read()
|
||||
source = source.replace('from .format_detection import', 'from format_detection import')
|
||||
source = source.replace('from .model_mappings import', 'from model_mappings import')
|
||||
source = source.replace('from .iterable import', 'from iterable import')
|
||||
exec(source, ns)
|
||||
|
||||
assert 'DEFAULT_ALPACA_TEMPLATE' in ns, "DEFAULT_ALPACA_TEMPLATE not defined"
|
||||
assert 'Instruction' in ns['DEFAULT_ALPACA_TEMPLATE'], "Template content unexpected"
|
||||
print("OK: DEFAULT_ALPACA_TEMPLATE defined and valid")
|
||||
""")
|
||||
result = subprocess.run(
|
||||
[no_torch_venv, "-c", code],
|
||||
capture_output = True,
|
||||
timeout = 30,
|
||||
)
|
||||
assert (
|
||||
result.returncode == 0
|
||||
), f"DEFAULT_ALPACA_TEMPLATE check failed:\n{result.stderr.decode()}"
|
||||
assert b"OK: DEFAULT_ALPACA_TEMPLATE defined and valid" in result.stdout
|
||||
|
||||
|
||||
# ── format_conversion.py: AST + runtime tests ────────────────────────
|
||||
|
||||
|
||||
class TestFormatConversionAST:
|
||||
"""Static analysis: format_conversion.py torch imports are guarded."""
|
||||
|
||||
def test_ast_parse(self):
|
||||
"""format_conversion.py must be valid Python syntax."""
|
||||
source = FORMAT_CONVERSION.read_text(encoding = "utf-8")
|
||||
tree = ast.parse(source, filename = str(FORMAT_CONVERSION))
|
||||
assert tree is not None
|
||||
|
||||
def test_no_bare_torch_import_in_functions(self):
|
||||
"""All 'from torch' imports in function bodies must be inside try/except."""
|
||||
source = FORMAT_CONVERSION.read_text(encoding = "utf-8")
|
||||
tree = ast.parse(source)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
for child in ast.walk(node):
|
||||
if (
|
||||
isinstance(child, ast.ImportFrom)
|
||||
and child.module
|
||||
and child.module.startswith("torch")
|
||||
):
|
||||
# This torch import must be inside a Try node.
|
||||
found_in_try = False
|
||||
for try_node in ast.walk(node):
|
||||
if isinstance(try_node, ast.Try):
|
||||
for try_child in ast.walk(try_node):
|
||||
if try_child is child:
|
||||
found_in_try = True
|
||||
break
|
||||
if found_in_try:
|
||||
break
|
||||
assert found_in_try, (
|
||||
f"torch import at line {child.lineno} in {node.name}() "
|
||||
"is not inside a try/except block"
|
||||
)
|
||||
|
||||
|
||||
class TestFormatConversionNoTorchVenv:
|
||||
"""Run format_conversion.py functions in a no-torch venv."""
|
||||
|
||||
def test_convert_chatml_to_alpaca_no_torch(self, no_torch_venv):
|
||||
"""convert_chatml_to_alpaca works without torch (via try/except ImportError)."""
|
||||
code = textwrap.dedent(f"""\
|
||||
import sys, types
|
||||
|
||||
# Stub loggers
|
||||
loggers = types.ModuleType('loggers')
|
||||
loggers.get_logger = lambda n: type('L', (), {{
|
||||
'info': lambda s, m: None,
|
||||
'warning': lambda s, m: None,
|
||||
'debug': lambda s, m: None,
|
||||
}})()
|
||||
sys.modules['loggers'] = loggers
|
||||
|
||||
# Stub datasets.IterableDataset (HF datasets, not torch)
|
||||
datasets_mod = types.ModuleType('datasets')
|
||||
datasets_mod.IterableDataset = type('IterableDataset', (), {{}})
|
||||
sys.modules['datasets'] = datasets_mod
|
||||
|
||||
iterable_mod = types.ModuleType('iterable')
|
||||
iterable_mod.is_streaming_dataset = lambda *a, **k: False
|
||||
sys.modules['iterable'] = iterable_mod
|
||||
|
||||
# Stub utils.hardware
|
||||
utils_mod = types.ModuleType('utils')
|
||||
hardware_mod = types.ModuleType('utils.hardware')
|
||||
hardware_mod.dataset_map_num_proc = lambda n=None: 1
|
||||
utils_mod.hardware = hardware_mod
|
||||
sys.modules['utils'] = utils_mod
|
||||
sys.modules['utils.hardware'] = hardware_mod
|
||||
|
||||
# Read and exec format_conversion.py
|
||||
source = open({str(FORMAT_CONVERSION)!r}).read()
|
||||
source = source.replace('from .format_detection import', 'from format_detection import')
|
||||
source = source.replace('from .iterable import', 'from iterable import')
|
||||
ns = {{'__name__': '__test__'}}
|
||||
exec(source, ns)
|
||||
|
||||
# Test convert_chatml_to_alpaca with a simple dataset
|
||||
class FakeDataset:
|
||||
def map(self, fn, **kw):
|
||||
result = fn({{
|
||||
'messages': [[
|
||||
{{'role': 'user', 'content': 'Hello'}},
|
||||
{{'role': 'assistant', 'content': 'Hi there'}},
|
||||
]]
|
||||
}})
|
||||
return result
|
||||
|
||||
result = ns['convert_chatml_to_alpaca'](FakeDataset())
|
||||
assert 'instruction' in result, f"Expected 'instruction' in result, got {{result.keys()}}"
|
||||
assert result['instruction'] == ['Hello']
|
||||
assert result['output'] == ['Hi there']
|
||||
print("OK: convert_chatml_to_alpaca works without torch")
|
||||
""")
|
||||
result = subprocess.run(
|
||||
[no_torch_venv, "-c", code],
|
||||
capture_output = True,
|
||||
timeout = 30,
|
||||
)
|
||||
assert (
|
||||
result.returncode == 0
|
||||
), f"convert_chatml_to_alpaca failed without torch:\n{result.stderr.decode()}"
|
||||
assert b"OK: convert_chatml_to_alpaca works without torch" in result.stdout
|
||||
|
||||
def test_convert_alpaca_to_chatml_no_torch(self, no_torch_venv):
|
||||
"""convert_alpaca_to_chatml works without torch (via try/except ImportError)."""
|
||||
code = textwrap.dedent(f"""\
|
||||
import sys, types
|
||||
|
||||
loggers = types.ModuleType('loggers')
|
||||
loggers.get_logger = lambda n: type('L', (), {{
|
||||
'info': lambda s, m: None,
|
||||
'warning': lambda s, m: None,
|
||||
'debug': lambda s, m: None,
|
||||
}})()
|
||||
sys.modules['loggers'] = loggers
|
||||
|
||||
datasets_mod = types.ModuleType('datasets')
|
||||
datasets_mod.IterableDataset = type('IterableDataset', (), {{}})
|
||||
sys.modules['datasets'] = datasets_mod
|
||||
|
||||
iterable_mod = types.ModuleType('iterable')
|
||||
iterable_mod.is_streaming_dataset = lambda *a, **k: False
|
||||
sys.modules['iterable'] = iterable_mod
|
||||
|
||||
utils_mod = types.ModuleType('utils')
|
||||
hardware_mod = types.ModuleType('utils.hardware')
|
||||
hardware_mod.dataset_map_num_proc = lambda n=None: 1
|
||||
utils_mod.hardware = hardware_mod
|
||||
sys.modules['utils'] = utils_mod
|
||||
sys.modules['utils.hardware'] = hardware_mod
|
||||
|
||||
source = open({str(FORMAT_CONVERSION)!r}).read()
|
||||
source = source.replace('from .format_detection import', 'from format_detection import')
|
||||
source = source.replace('from .iterable import', 'from iterable import')
|
||||
ns = {{'__name__': '__test__'}}
|
||||
exec(source, ns)
|
||||
|
||||
class FakeDataset:
|
||||
def map(self, fn, **kw):
|
||||
result = fn({{
|
||||
'instruction': ['Write a poem'],
|
||||
'input': [''],
|
||||
'output': ['Roses are red'],
|
||||
}})
|
||||
return result
|
||||
|
||||
result = ns['convert_alpaca_to_chatml'](FakeDataset())
|
||||
assert 'conversations' in result
|
||||
convo = result['conversations'][0]
|
||||
assert convo[0]['role'] == 'user'
|
||||
assert convo[1]['role'] == 'assistant'
|
||||
print("OK: convert_alpaca_to_chatml works without torch")
|
||||
""")
|
||||
result = subprocess.run(
|
||||
[no_torch_venv, "-c", code],
|
||||
capture_output = True,
|
||||
timeout = 30,
|
||||
)
|
||||
assert (
|
||||
result.returncode == 0
|
||||
), f"convert_alpaca_to_chatml failed without torch:\n{result.stderr.decode()}"
|
||||
assert b"OK: convert_alpaca_to_chatml works without torch" in result.stdout
|
||||
|
||||
|
||||
# ── Negative controls ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNegativeControls:
|
||||
"""Prove the fix is necessary by showing what fails WITHOUT it."""
|
||||
|
||||
def test_import_torch_prepended_fails(self, no_torch_venv):
|
||||
"""Prepending 'import torch' to data_collators.py causes ModuleNotFoundError."""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode = "w", suffix = ".py", delete = False, encoding = "utf-8"
|
||||
) as f:
|
||||
f.write("import torch\n")
|
||||
f.write(DATA_COLLATORS.read_text(encoding = "utf-8"))
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
code = textwrap.dedent(f"""\
|
||||
import sys, types
|
||||
loggers = types.ModuleType('loggers')
|
||||
loggers.get_logger = lambda n: None
|
||||
sys.modules['loggers'] = loggers
|
||||
exec(open({temp_file!r}).read())
|
||||
""")
|
||||
result = subprocess.run(
|
||||
[no_torch_venv, "-c", code],
|
||||
capture_output = True,
|
||||
timeout = 30,
|
||||
)
|
||||
assert result.returncode != 0, "Expected failure when 'import torch' is prepended"
|
||||
assert (
|
||||
b"ModuleNotFoundError" in result.stderr or b"ImportError" in result.stderr
|
||||
), f"Expected ImportError, got:\n{result.stderr.decode()}"
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
def test_torchao_install_fails_no_torch_venv(self, no_torch_venv):
|
||||
"""torchao install fails in a no-torch venv: proves the overrides.txt skip is needed."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
no_torch_venv,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"torchao==0.14.0",
|
||||
"--dry-run",
|
||||
],
|
||||
capture_output = True,
|
||||
timeout = 60,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# torchao install/resolution failed as expected.
|
||||
pass
|
||||
else:
|
||||
# dry-run may miss dep issues; verify torch is absent instead.
|
||||
check = subprocess.run(
|
||||
[no_torch_venv, "-c", "import torch"],
|
||||
capture_output = True,
|
||||
)
|
||||
assert (
|
||||
check.returncode != 0
|
||||
), "torch should not be importable -- torchao would fail at runtime"
|
||||
|
||||
def test_direct_torch_import_fails(self, no_torch_venv):
|
||||
"""Direct 'import torch' fails in the no-torch venv."""
|
||||
result = subprocess.run(
|
||||
[no_torch_venv, "-c", "import torch; print('torch loaded')"],
|
||||
capture_output = True,
|
||||
timeout = 30,
|
||||
)
|
||||
assert result.returncode != 0, "import torch should fail in no-torch venv"
|
||||
assert b"ModuleNotFoundError" in result.stderr or b"ImportError" in result.stderr
|
||||
@@ -0,0 +1,97 @@
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_formatter_builders():
|
||||
# Extract _parse_combined_prompt and _create_formatter without importing
|
||||
# unsloth (importing unsloth needs unsloth_zoo / a GPU). Both are pure
|
||||
# Python and only use the `re` module.
|
||||
source = Path(__file__).parents[2] / "unsloth" / "chat_templates.py"
|
||||
tree = ast.parse(source.read_text(encoding = "utf-8"))
|
||||
wanted = {"_parse_combined_prompt", "_create_formatter"}
|
||||
funcs = [
|
||||
node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in wanted
|
||||
]
|
||||
namespace = {"re": re}
|
||||
module = ast.Module(body = funcs, type_ignores = [])
|
||||
ast.fix_missing_locations(module)
|
||||
exec(compile(module, str(source), "exec"), namespace)
|
||||
return namespace["_parse_combined_prompt"], namespace["_create_formatter"]
|
||||
|
||||
|
||||
class _StubDataset:
|
||||
def __init__(self, column_names):
|
||||
self.column_names = column_names
|
||||
|
||||
|
||||
def _render(merged_prompt, columns, batch):
|
||||
parse, create = _load_formatter_builders()
|
||||
possible_columns, final_optional_prompts = parse(merged_prompt, _StubDataset(columns))
|
||||
processor = create(possible_columns, final_optional_prompts, "text")
|
||||
return processor(batch)["text"]
|
||||
|
||||
|
||||
def test_optional_block_missing_second_column_does_not_render_none():
|
||||
# A [[...]] block may reference several columns; only the first gates the
|
||||
# block. A later column that is None must not render as the literal "None".
|
||||
merged_prompt = "Location: [[{city}, {country}]] end"
|
||||
out = _render(
|
||||
merged_prompt,
|
||||
["city", "country"],
|
||||
{"city": ["Paris"], "country": [None]},
|
||||
)
|
||||
assert out[0] == "Location: Paris, end"
|
||||
assert "None" not in out[0]
|
||||
|
||||
|
||||
def test_optional_block_all_columns_present_unchanged():
|
||||
merged_prompt = "Location: [[{city}, {country}]] end"
|
||||
out = _render(
|
||||
merged_prompt,
|
||||
["city", "country"],
|
||||
{"city": ["Paris"], "country": ["France"]},
|
||||
)
|
||||
assert out[0] == "Location: Paris, France end"
|
||||
|
||||
|
||||
def test_optional_block_gating_column_empty_is_dropped():
|
||||
# When the gating (first) column is empty the whole block is omitted; this
|
||||
# behaviour is unchanged by the None coercion.
|
||||
merged_prompt = "Location: [[{city}, {country}]] end"
|
||||
out = _render(
|
||||
merged_prompt,
|
||||
["city", "country"],
|
||||
{"city": [""], "country": ["France"]},
|
||||
)
|
||||
assert out[0] == "Location: end"
|
||||
|
||||
|
||||
def test_single_column_optional_block_gated_out_on_none():
|
||||
# Single-column blocks were already gated correctly (the sole column is the
|
||||
# gate); confirm they stay unaffected.
|
||||
merged_prompt = "Name: [[{name}]]!"
|
||||
out = _render(merged_prompt, ["name"], {"name": [None, "Bob"]})
|
||||
assert out == ["Name: !", "Name: Bob!"]
|
||||
|
||||
|
||||
def test_required_column_none_does_not_render_none():
|
||||
# A required (non-[[...]]) column that is None must not render as the
|
||||
# literal "None" either; coercion happens at the row source, so both the
|
||||
# required and optional branches are covered.
|
||||
merged_prompt = "Location: {city}, {country} end"
|
||||
out = _render(
|
||||
merged_prompt,
|
||||
["city", "country"],
|
||||
{"city": ["Paris"], "country": [None]},
|
||||
)
|
||||
assert out[0] == "Location: Paris, end"
|
||||
assert "None" not in out[0]
|
||||
|
||||
|
||||
def test_optional_block_falsy_but_present_gating_value_still_renders():
|
||||
# The gate keeps a block whenever the first column is not "". A falsy but
|
||||
# real value (0) must not be treated as absent, so the block still renders.
|
||||
merged_prompt = "Count: [[{n}]]!"
|
||||
out = _render(merged_prompt, ["n"], {"n": [0]})
|
||||
assert out[0] == "Count: 0!"
|
||||
@@ -0,0 +1,569 @@
|
||||
"""Install fixes: tokenizers in no-torch-runtime.txt, and TORCH_CONSTRAINT in install.sh."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
# Locate source files relative to this test.
|
||||
_TESTS_DIR = pathlib.Path(__file__).resolve().parent.parent # tests/
|
||||
_REPO_ROOT = _TESTS_DIR.parent # unsloth/
|
||||
_INSTALL_SH = _REPO_ROOT / "install.sh"
|
||||
_INSTALL_PS1 = _REPO_ROOT / "install.ps1"
|
||||
_SETUP_PS1 = _REPO_ROOT / "studio" / "setup.ps1"
|
||||
_NO_TORCH_RT = _REPO_ROOT / "studio" / "backend" / "requirements" / "no-torch-runtime.txt"
|
||||
|
||||
|
||||
def _read(path: pathlib.Path) -> str:
|
||||
return path.read_text(encoding = "utf-8")
|
||||
|
||||
|
||||
def _lines(path: pathlib.Path) -> list[str]:
|
||||
"""Return non-comment, non-blank lines stripped."""
|
||||
return [
|
||||
ln.strip()
|
||||
for ln in _read(path).splitlines()
|
||||
if ln.strip() and not ln.strip().startswith("#")
|
||||
]
|
||||
|
||||
|
||||
# Group 1 -- Structural checks (no network, instant)
|
||||
class TestStructuralTokenizers:
|
||||
"""Verify tokenizers presence and ordering in no-torch-runtime.txt."""
|
||||
|
||||
def test_tokenizers_present(self):
|
||||
"""tokenizers must be a standalone package line."""
|
||||
pkgs = _lines(_NO_TORCH_RT)
|
||||
bare_names = [p.split(">")[0].split("<")[0].split("!")[0].split("=")[0] for p in pkgs]
|
||||
assert "tokenizers" in bare_names
|
||||
|
||||
def test_tokenizers_before_transformers(self):
|
||||
"""tokenizers should appear before transformers (install order intent)."""
|
||||
pkgs = _lines(_NO_TORCH_RT)
|
||||
bare_names = [p.split(">")[0].split("<")[0].split("!")[0].split("=")[0] for p in pkgs]
|
||||
idx_tok = bare_names.index("tokenizers")
|
||||
idx_tf = bare_names.index("transformers")
|
||||
assert idx_tok < idx_tf, (
|
||||
f"tokenizers at index {idx_tok} should appear before " f"transformers at index {idx_tf}"
|
||||
)
|
||||
|
||||
def test_torch_not_in_no_torch_file(self):
|
||||
"""torch itself must NOT be listed in the no-torch requirements."""
|
||||
pkgs = _lines(_NO_TORCH_RT)
|
||||
bare_names = [p.split(">")[0].split("<")[0].split("!")[0].split("=")[0] for p in pkgs]
|
||||
assert "torch" not in bare_names
|
||||
|
||||
|
||||
class TestStructuralTorchConstraint:
|
||||
"""Verify TORCH_CONSTRAINT wiring in install.sh."""
|
||||
|
||||
_sh = _read(_INSTALL_SH)
|
||||
|
||||
def test_default_assignment_exists(self):
|
||||
assert 'TORCH_CONSTRAINT="torch>=2.4,<2.11.0"' in self._sh
|
||||
|
||||
def test_tightened_assignment_exists(self):
|
||||
assert 'TORCH_CONSTRAINT="torch>=2.6,<2.11.0"' in self._sh
|
||||
|
||||
def test_variable_used_in_pip_install(self):
|
||||
"""$TORCH_CONSTRAINT must appear in a uv pip install line."""
|
||||
assert '"$TORCH_CONSTRAINT"' in self._sh
|
||||
|
||||
def test_hardcoded_torch_constraint_only_once(self):
|
||||
"""The hard-coded torch>=2.4,<2.11.0 string should appear exactly once
|
||||
in install.sh (the default assignment), not in pip install lines."""
|
||||
count = self._sh.count('"torch>=2.4,<2.11.0"')
|
||||
assert count == 1, f"Expected 1, found {count}"
|
||||
|
||||
def test_tightening_guarded_by_skip_torch(self):
|
||||
"""The block must check SKIP_TORCH=false."""
|
||||
# Find the tightening if-block
|
||||
m = re.search(
|
||||
r"if\s.*SKIP_TORCH.*=\s*false.*&&.*OS.*=.*macos.*&&.*_ARCH.*=.*arm64",
|
||||
self._sh,
|
||||
)
|
||||
assert m is not None, "Guard not found: SKIP_TORCH + macos + arm64"
|
||||
|
||||
def test_tightening_guarded_by_arch(self):
|
||||
m = re.search(r"_ARCH.*=.*arm64", self._sh)
|
||||
assert m is not None
|
||||
|
||||
def test_tightening_guarded_by_os(self):
|
||||
m = re.search(r"OS.*=.*macos", self._sh)
|
||||
assert m is not None
|
||||
|
||||
|
||||
class TestStructuralInstallPs1Unchanged:
|
||||
"""install.ps1 should NOT have TORCH_CONSTRAINT variable."""
|
||||
|
||||
_ps1 = _read(_INSTALL_PS1)
|
||||
|
||||
def test_no_torch_constraint_variable(self):
|
||||
assert "TORCH_CONSTRAINT" not in self._ps1
|
||||
assert "$TorchConstraint" not in self._ps1
|
||||
|
||||
def test_hardcoded_torch_constraint_present(self):
|
||||
assert '"torch>=2.4,<2.11.0"' in self._ps1
|
||||
|
||||
|
||||
class TestInstallPs1UvDefaultIndex:
|
||||
"""Installer-managed torch indexes must override inherited uv defaults."""
|
||||
|
||||
_ps1 = _read(_INSTALL_PS1)
|
||||
|
||||
def test_torch_installs_use_default_index(self):
|
||||
assert "--default-index $TorchIndexUrl" in self._ps1
|
||||
assert "--default-index $ROCmIndexUrl" in self._ps1
|
||||
|
||||
def test_torch_installs_do_not_use_deprecated_index_url(self):
|
||||
assert "--index-url $TorchIndexUrl" not in self._ps1
|
||||
assert "--index-url $ROCmIndexUrl" not in self._ps1
|
||||
|
||||
def test_torch_installs_neutralize_all_uv_index_env_vars(self):
|
||||
# Extra-index vars outrank --default-index, so pinned installs must clear them.
|
||||
for var in ("UV_DEFAULT_INDEX", "UV_INDEX_URL", "UV_INDEX", "UV_EXTRA_INDEX_URL"):
|
||||
assert var in self._ps1
|
||||
assert 'Remove-Item "Env:$n"' in self._ps1
|
||||
|
||||
|
||||
class TestSetupPs1FastInstallIndex:
|
||||
"""setup.ps1 Fast-Install must neutralize inherited uv indexes when pinning."""
|
||||
|
||||
_ps1 = _read(_SETUP_PS1)
|
||||
|
||||
def test_fast_install_clears_all_uv_index_env_vars(self):
|
||||
for var in ("UV_DEFAULT_INDEX", "UV_INDEX_URL", "UV_INDEX", "UV_EXTRA_INDEX_URL"):
|
||||
assert var in self._ps1
|
||||
# Must truly remove the vars (child sees no value), not set them empty.
|
||||
assert 'Remove-Item "Env:$n"' in self._ps1
|
||||
|
||||
|
||||
class TestInstallShUvDefaultIndex:
|
||||
"""Linux/Mac installer torch indexes must override inherited uv defaults."""
|
||||
|
||||
_sh = _read(_INSTALL_SH)
|
||||
|
||||
def test_torch_installs_use_default_index(self):
|
||||
assert '--default-index "$TORCH_INDEX_URL"' in self._sh
|
||||
|
||||
def test_torch_installs_do_not_use_deprecated_index_url(self):
|
||||
assert '--index-url "$TORCH_INDEX_URL"' not in self._sh
|
||||
|
||||
def test_torch_installs_neutralize_all_uv_index_env_vars(self):
|
||||
# --default-index installs run with all uv index env vars unset via `env -u`.
|
||||
assert (
|
||||
"env -u UV_DEFAULT_INDEX -u UV_INDEX_URL -u UV_INDEX -u UV_EXTRA_INDEX_URL" in self._sh
|
||||
)
|
||||
|
||||
|
||||
# Group 2 -- Shell snippet tests (bash subprocess, mocked python)
|
||||
class TestTorchConstraintShell:
|
||||
"""Test the TORCH_CONSTRAINT block via bash with mocked python minor versions."""
|
||||
|
||||
# Snippet tested in isolation: override OS/_ARCH/SKIP_TORCH and a mock python.
|
||||
_SNIPPET_TEMPLATE = textwrap.dedent(r"""
|
||||
#!/bin/bash
|
||||
set -e
|
||||
SKIP_TORCH={skip_torch}
|
||||
OS="{os}"
|
||||
_ARCH="{arch}"
|
||||
VENV_DIR="{venv_dir}"
|
||||
|
||||
TORCH_CONSTRAINT="torch>=2.4,<2.11.0"
|
||||
if [ "$SKIP_TORCH" = false ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
|
||||
_PY_MINOR=$("$VENV_DIR/bin/python" -c \
|
||||
"import sys; print(sys.version_info.minor)" 2>/dev/null || echo "0")
|
||||
if [ "$_PY_MINOR" -ge 13 ] 2>/dev/null; then
|
||||
TORCH_CONSTRAINT="torch>=2.6,<2.11.0"
|
||||
fi
|
||||
fi
|
||||
echo "$TORCH_CONSTRAINT"
|
||||
""").strip()
|
||||
|
||||
@staticmethod
|
||||
def _make_mock_python(tmp_path: pathlib.Path, minor: int) -> pathlib.Path:
|
||||
"""Create a mock python that prints a controlled minor version."""
|
||||
venv = tmp_path / "venv"
|
||||
bin_dir = venv / "bin"
|
||||
bin_dir.mkdir(parents = True, exist_ok = True)
|
||||
mock_py = bin_dir / "python"
|
||||
mock_py.write_text(
|
||||
textwrap.dedent(f"""\
|
||||
#!/bin/bash
|
||||
# Mock python: always report minor={minor}
|
||||
if echo "$@" | grep -q "sys.version_info.minor"; then
|
||||
echo "{minor}"
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
""")
|
||||
)
|
||||
mock_py.chmod(0o755)
|
||||
return venv
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tmp_path: pathlib.Path,
|
||||
*,
|
||||
py_minor: int = 12,
|
||||
os_val: str = "macos",
|
||||
arch: str = "arm64",
|
||||
skip_torch: str = "false",
|
||||
) -> str:
|
||||
venv = self._make_mock_python(tmp_path, py_minor)
|
||||
script = self._SNIPPET_TEMPLATE.format(
|
||||
skip_torch = skip_torch,
|
||||
os = os_val,
|
||||
arch = arch,
|
||||
venv_dir = str(venv),
|
||||
)
|
||||
script_file = tmp_path / "test_snippet.sh"
|
||||
script_file.write_text(script)
|
||||
script_file.chmod(0o755)
|
||||
result = subprocess.run(
|
||||
["bash", str(script_file)],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 10,
|
||||
)
|
||||
assert result.returncode == 0, f"Script failed: {result.stderr}"
|
||||
return result.stdout.strip()
|
||||
|
||||
def test_arm64_macos_py313_tightened(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 13, os_val = "macos", arch = "arm64")
|
||||
assert out == "torch>=2.6,<2.11.0"
|
||||
|
||||
def test_arm64_macos_py314_tightened(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 14, os_val = "macos", arch = "arm64")
|
||||
assert out == "torch>=2.6,<2.11.0"
|
||||
|
||||
def test_arm64_macos_py312_default(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 12, os_val = "macos", arch = "arm64")
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
def test_arm64_macos_py311_default(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 11, os_val = "macos", arch = "arm64")
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
# Linux is unaffected by the tightening.
|
||||
def test_linux_x86_py313_default(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 13, os_val = "linux", arch = "x86_64")
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
def test_linux_aarch64_py313_default(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 13, os_val = "linux", arch = "aarch64")
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
# Intel Mac: arch mismatch, no tightening.
|
||||
def test_intel_mac_x86_py313_default(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 13, os_val = "macos", arch = "x86_64")
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
# SKIP_TORCH bypasses the tightening.
|
||||
def test_skip_torch_arm64_macos_py313_default(self, tmp_path):
|
||||
out = self._run(
|
||||
tmp_path,
|
||||
py_minor = 13,
|
||||
os_val = "macos",
|
||||
arch = "arm64",
|
||||
skip_torch = "true",
|
||||
)
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
def test_wsl_py313_default(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 13, os_val = "wsl", arch = "x86_64")
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
def test_py_minor_0_fallback_default(self, tmp_path):
|
||||
"""Failed python query (returns 0) keeps the default constraint."""
|
||||
out = self._run(tmp_path, py_minor = 0, os_val = "macos", arch = "arm64")
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
def test_boundary_py_minor_12_not_tightened(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 12, os_val = "macos", arch = "arm64")
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
def test_boundary_py_minor_13_tightened(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 13, os_val = "macos", arch = "arm64")
|
||||
assert out == "torch>=2.6,<2.11.0"
|
||||
|
||||
def test_mock_uv_receives_correct_constraint(self, tmp_path):
|
||||
"""A mock uv receives the tightened constraint on py3.13 arm64 macOS."""
|
||||
venv = self._make_mock_python(tmp_path, minor = 13)
|
||||
|
||||
# Mock uv logs its arguments.
|
||||
mock_uv = tmp_path / "mock_uv"
|
||||
log_file = tmp_path / "uv_log.txt"
|
||||
mock_uv.write_text(
|
||||
textwrap.dedent(f"""\
|
||||
#!/bin/bash
|
||||
echo "$@" >> {log_file}
|
||||
""")
|
||||
)
|
||||
mock_uv.chmod(0o755)
|
||||
|
||||
script = textwrap.dedent(f"""\
|
||||
#!/bin/bash
|
||||
set -e
|
||||
SKIP_TORCH=false
|
||||
OS="macos"
|
||||
_ARCH="arm64"
|
||||
VENV_DIR="{venv}"
|
||||
|
||||
TORCH_CONSTRAINT="torch>=2.4,<2.11.0"
|
||||
if [ "$SKIP_TORCH" = false ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
|
||||
_PY_MINOR=$("$VENV_DIR/bin/python" -c \\
|
||||
"import sys; print(sys.version_info.minor)" 2>/dev/null || echo "0")
|
||||
if [ "$_PY_MINOR" -ge 13 ] 2>/dev/null; then
|
||||
TORCH_CONSTRAINT="torch>=2.6,<2.11.0"
|
||||
fi
|
||||
fi
|
||||
# Simulate the uv pip install line
|
||||
{mock_uv} pip install --python "$VENV_DIR/bin/python" "$TORCH_CONSTRAINT" torchvision torchaudio
|
||||
""")
|
||||
script_file = tmp_path / "test_uv.sh"
|
||||
script_file.write_text(script)
|
||||
script_file.chmod(0o755)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script_file)],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 10,
|
||||
)
|
||||
assert result.returncode == 0, f"Script failed: {result.stderr}"
|
||||
logged = log_file.read_text()
|
||||
assert "torch>=2.6,<2.11.0" in logged, f"uv log: {logged}"
|
||||
|
||||
def test_mock_uv_receives_default_constraint(self, tmp_path):
|
||||
"""On py3.12 arm64 macOS, uv should receive the default constraint."""
|
||||
venv = self._make_mock_python(tmp_path, minor = 12)
|
||||
mock_uv = tmp_path / "mock_uv"
|
||||
log_file = tmp_path / "uv_log.txt"
|
||||
mock_uv.write_text(
|
||||
textwrap.dedent(f"""\
|
||||
#!/bin/bash
|
||||
echo "$@" >> {log_file}
|
||||
""")
|
||||
)
|
||||
mock_uv.chmod(0o755)
|
||||
|
||||
script = textwrap.dedent(f"""\
|
||||
#!/bin/bash
|
||||
set -e
|
||||
SKIP_TORCH=false
|
||||
OS="macos"
|
||||
_ARCH="arm64"
|
||||
VENV_DIR="{venv}"
|
||||
|
||||
TORCH_CONSTRAINT="torch>=2.4,<2.11.0"
|
||||
if [ "$SKIP_TORCH" = false ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
|
||||
_PY_MINOR=$("$VENV_DIR/bin/python" -c \\
|
||||
"import sys; print(sys.version_info.minor)" 2>/dev/null || echo "0")
|
||||
if [ "$_PY_MINOR" -ge 13 ] 2>/dev/null; then
|
||||
TORCH_CONSTRAINT="torch>=2.6,<2.11.0"
|
||||
fi
|
||||
fi
|
||||
{mock_uv} pip install --python "$VENV_DIR/bin/python" "$TORCH_CONSTRAINT" torchvision torchaudio
|
||||
""")
|
||||
script_file = tmp_path / "test_uv.sh"
|
||||
script_file.write_text(script)
|
||||
script_file.chmod(0o755)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script_file)],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 10,
|
||||
)
|
||||
assert result.returncode == 0, f"Script failed: {result.stderr}"
|
||||
logged = log_file.read_text()
|
||||
assert "torch>=2.4,<2.11.0" in logged, f"uv log: {logged}"
|
||||
|
||||
|
||||
# Group 3 -- E2E tokenizers fix (requires network, ~2-5 min)
|
||||
@pytest.mark.e2e
|
||||
class TestE2ETokenizersFix:
|
||||
"""Real uv venvs verify tokenizers + transformers work without torch installed."""
|
||||
|
||||
@staticmethod
|
||||
def _create_venv(tmp_path: pathlib.Path, name: str, py: str) -> pathlib.Path:
|
||||
venv = tmp_path / name
|
||||
result = subprocess.run(
|
||||
["uv", "venv", str(venv), "--python", py],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.skip(f"uv venv creation failed for {py}: {result.stderr}")
|
||||
return venv
|
||||
|
||||
@staticmethod
|
||||
def _pip_install(venv: pathlib.Path, *args: str) -> subprocess.CompletedProcess:
|
||||
py = str(venv / "bin" / "python")
|
||||
cmd = ["uv", "pip", "install", "--python", py, *args]
|
||||
return subprocess.run(cmd, capture_output = True, text = True, timeout = 300)
|
||||
|
||||
@staticmethod
|
||||
def _run_python(venv: pathlib.Path, code: str) -> subprocess.CompletedProcess:
|
||||
py = str(venv / "bin" / "python")
|
||||
return subprocess.run(
|
||||
[py, "-c", code],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 60,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("py_version", ["3.12", "3.13"])
|
||||
def test_autoconfig_works_with_no_torch_runtime(self, tmp_path, py_version):
|
||||
"""Install no-torch-runtime.txt with --no-deps, then AutoConfig must import."""
|
||||
venv = self._create_venv(tmp_path, f"tok-{py_version}", py_version)
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
|
||||
result = self._run_python(venv, "from transformers import AutoConfig; print('OK')")
|
||||
assert (
|
||||
result.returncode == 0
|
||||
), f"AutoConfig import failed:\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
||||
assert "OK" in result.stdout
|
||||
|
||||
@pytest.mark.parametrize("py_version", ["3.12", "3.13"])
|
||||
def test_tokenizers_directly_importable(self, tmp_path, py_version):
|
||||
venv = self._create_venv(tmp_path, f"tok-imp-{py_version}", py_version)
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
result = self._run_python(venv, "import tokenizers; print('OK')")
|
||||
assert result.returncode == 0, f"Failed: {result.stderr}"
|
||||
|
||||
@pytest.mark.parametrize("py_version", ["3.12", "3.13"])
|
||||
def test_torch_not_importable(self, tmp_path, py_version):
|
||||
"""In the no-torch scenario, torch should not be available."""
|
||||
venv = self._create_venv(tmp_path, f"no-torch-{py_version}", py_version)
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
result = self._run_python(venv, "import torch")
|
||||
assert result.returncode != 0, "torch should NOT be importable"
|
||||
|
||||
def test_negative_control_no_tokenizers(self, tmp_path):
|
||||
"""Without the tokenizers line, AutoConfig must fail (negative control)."""
|
||||
venv = self._create_venv(tmp_path, "neg-ctrl", "3.12")
|
||||
req_no_tokenizers = tmp_path / "no-tokenizers.txt"
|
||||
req_no_tokenizers.write_text(
|
||||
"\n".join(
|
||||
line for line in _read(_NO_TORCH_RT).splitlines() if line.strip() != "tokenizers"
|
||||
),
|
||||
encoding = "utf-8",
|
||||
)
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(req_no_tokenizers))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
result = self._run_python(venv, "from transformers import AutoConfig")
|
||||
assert result.returncode != 0, "AutoConfig should fail without tokenizers installed"
|
||||
assert "tokenizers" in result.stderr.lower() or "ModuleNotFoundError" in result.stderr
|
||||
|
||||
|
||||
# Group 4 -- Integration: install.sh reads no-torch-runtime.txt correctly
|
||||
class TestInstallShNoTorchIntegration:
|
||||
"""Verify install.sh has the correct no-torch-runtime.txt wiring."""
|
||||
|
||||
_sh = _read(_INSTALL_SH)
|
||||
|
||||
def test_find_no_torch_runtime_exists(self):
|
||||
assert "_find_no_torch_runtime()" in self._sh
|
||||
|
||||
def test_no_deps_invocation_for_migrated(self):
|
||||
"""Migrated path should use --no-deps -r."""
|
||||
assert '--no-deps -r "$_NO_TORCH_RT"' in self._sh
|
||||
|
||||
def test_no_deps_invocation_for_fresh(self):
|
||||
"""Fresh install path should also use --no-deps -r."""
|
||||
count = self._sh.count('--no-deps -r "$_NO_TORCH_RT"')
|
||||
assert count >= 2, f"Expected >=2 no-deps -r invocations, found {count}"
|
||||
|
||||
def test_mock_uv_skip_torch_reads_requirements(self, tmp_path):
|
||||
"""SKIP_TORCH=true blocks must call _find_no_torch_runtime."""
|
||||
skip_blocks = re.findall(
|
||||
r'if \[ "\$SKIP_TORCH" = true \].*?(?=\n (?:else|elif|fi))',
|
||||
self._sh,
|
||||
re.DOTALL,
|
||||
)
|
||||
found = any("_find_no_torch_runtime" in block for block in skip_blocks)
|
||||
assert found, "SKIP_TORCH=true block should call _find_no_torch_runtime"
|
||||
|
||||
|
||||
# Group 5 -- Full no-torch sandbox (requires network, ~5 min)
|
||||
@pytest.mark.e2e
|
||||
class TestE2EFullNoTorchSandbox:
|
||||
"""Creates venvs and installs the actual no-torch-runtime.txt."""
|
||||
|
||||
@staticmethod
|
||||
def _create_venv(tmp_path: pathlib.Path, name: str) -> pathlib.Path:
|
||||
venv = tmp_path / name
|
||||
result = subprocess.run(
|
||||
["uv", "venv", str(venv), "--python", "3.12"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.skip(f"uv venv creation failed: {result.stderr}")
|
||||
return venv
|
||||
|
||||
@staticmethod
|
||||
def _pip_install(venv: pathlib.Path, *args: str) -> subprocess.CompletedProcess:
|
||||
py = str(venv / "bin" / "python")
|
||||
cmd = ["uv", "pip", "install", "--python", py, *args]
|
||||
return subprocess.run(cmd, capture_output = True, text = True, timeout = 600)
|
||||
|
||||
@staticmethod
|
||||
def _run_python(venv: pathlib.Path, code: str) -> subprocess.CompletedProcess:
|
||||
py = str(venv / "bin" / "python")
|
||||
return subprocess.run(
|
||||
[py, "-c", code],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 60,
|
||||
)
|
||||
|
||||
def test_autoconfig_succeeds(self, tmp_path):
|
||||
"""Install with --no-deps and verify AutoConfig imports (the bug fix)."""
|
||||
venv = self._create_venv(tmp_path, "full-no-torch")
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
result = self._run_python(venv, "from transformers import AutoConfig; print('OK')")
|
||||
assert (
|
||||
result.returncode == 0
|
||||
), f"AutoConfig failed:\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
||||
|
||||
def test_torch_not_importable(self, tmp_path):
|
||||
"""With --no-deps, torch must not be pulled in."""
|
||||
venv = self._create_venv(tmp_path, "no-torch-check")
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
result = self._run_python(venv, "import torch")
|
||||
assert result.returncode != 0, "torch should NOT be importable"
|
||||
|
||||
def test_tokenizers_importable(self, tmp_path):
|
||||
venv = self._create_venv(tmp_path, "tok-check")
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
result = self._run_python(venv, "import tokenizers; print('OK')")
|
||||
assert result.returncode == 0, f"tokenizers import failed: {result.stderr}"
|
||||
|
||||
def test_safetensors_importable(self, tmp_path):
|
||||
venv = self._create_venv(tmp_path, "st-check")
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
result = self._run_python(venv, "import safetensors; print('OK')")
|
||||
assert result.returncode == 0, f"safetensors import failed: {result.stderr}"
|
||||
|
||||
def test_huggingface_hub_importable(self, tmp_path):
|
||||
venv = self._create_venv(tmp_path, "hfhub-check")
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
result = self._run_python(venv, "import huggingface_hub; print('OK')")
|
||||
assert result.returncode == 0, f"huggingface_hub import failed: {result.stderr}"
|
||||
@@ -0,0 +1,167 @@
|
||||
# Copyright 2025-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
"""Truth-table tests for `resolve_tool_policy`: tools default on for every bind
|
||||
(loopback, --secure tunnel, raw network), explicit on/off wins, and the resolver
|
||||
never prompts (yes/silent/prompt kept for compatibility)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from unsloth_cli._tool_policy import is_external_host, resolve_tool_policy
|
||||
|
||||
|
||||
def _never_prompt(_msg: str) -> bool:
|
||||
raise AssertionError("resolve_tool_policy must not prompt")
|
||||
|
||||
|
||||
class TestLocalhostHost:
|
||||
@pytest.mark.parametrize("flag", [None, True, False])
|
||||
def test_no_prompt(self, flag):
|
||||
# localhost never prompts regardless of flag
|
||||
result = resolve_tool_policy(
|
||||
host = "127.0.0.1",
|
||||
flag = flag,
|
||||
yes = False,
|
||||
silent = False,
|
||||
prompt = _never_prompt,
|
||||
)
|
||||
assert result is (True if flag in (None, True) else False)
|
||||
|
||||
def test_default_is_on(self):
|
||||
assert (
|
||||
resolve_tool_policy(
|
||||
host = "127.0.0.1",
|
||||
flag = None,
|
||||
yes = False,
|
||||
silent = False,
|
||||
prompt = _never_prompt,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_explicit_off(self):
|
||||
assert (
|
||||
resolve_tool_policy(
|
||||
host = "127.0.0.1",
|
||||
flag = False,
|
||||
yes = False,
|
||||
silent = False,
|
||||
prompt = _never_prompt,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
class TestZeroHost:
|
||||
def test_default_is_on(self):
|
||||
# Network bind defaults ON now (operator owns network security).
|
||||
assert (
|
||||
resolve_tool_policy(
|
||||
host = "0.0.0.0",
|
||||
flag = None,
|
||||
yes = False,
|
||||
silent = False,
|
||||
prompt = _never_prompt,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_explicit_off_no_prompt(self):
|
||||
assert (
|
||||
resolve_tool_policy(
|
||||
host = "0.0.0.0",
|
||||
flag = False,
|
||||
yes = False,
|
||||
silent = False,
|
||||
prompt = _never_prompt,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_explicit_on_no_prompt(self):
|
||||
assert (
|
||||
resolve_tool_policy(
|
||||
host = "0.0.0.0",
|
||||
flag = True,
|
||||
yes = False,
|
||||
silent = False,
|
||||
prompt = _never_prompt,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_yes_and_silent_accepted_but_do_not_change_result(self):
|
||||
# Retained for backward compatibility; they no longer gate the result.
|
||||
assert (
|
||||
resolve_tool_policy(
|
||||
host = "0.0.0.0",
|
||||
flag = None,
|
||||
yes = True,
|
||||
silent = True,
|
||||
prompt = _never_prompt,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
class TestIsExternalHost:
|
||||
@pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "::1", "LOCALHOST", "Localhost"])
|
||||
def test_loopback_aliases_are_local(self, host):
|
||||
assert is_external_host(host) is False
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"host", ["0.0.0.0", "::", "127.0.0.2", "192.168.1.5", "10.0.0.1", "example.com"]
|
||||
)
|
||||
def test_non_loopback_is_external(self, host):
|
||||
assert is_external_host(host) is True
|
||||
|
||||
|
||||
class TestSpecificNetworkIP:
|
||||
"""Binding to a specific LAN IP follows the same default-on rules as 0.0.0.0."""
|
||||
|
||||
def test_default_is_on(self):
|
||||
assert (
|
||||
resolve_tool_policy(
|
||||
host = "192.168.1.5",
|
||||
flag = None,
|
||||
yes = False,
|
||||
silent = False,
|
||||
prompt = _never_prompt,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_explicit_on_no_prompt(self):
|
||||
assert (
|
||||
resolve_tool_policy(
|
||||
host = "192.168.1.5",
|
||||
flag = True,
|
||||
yes = False,
|
||||
silent = False,
|
||||
prompt = _never_prompt,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_explicit_off(self):
|
||||
assert (
|
||||
resolve_tool_policy(
|
||||
host = "192.168.1.5",
|
||||
flag = False,
|
||||
yes = False,
|
||||
silent = False,
|
||||
prompt = _never_prompt,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_localhost_alias_does_not_prompt(self):
|
||||
assert (
|
||||
resolve_tool_policy(
|
||||
host = "localhost",
|
||||
flag = True,
|
||||
yes = False,
|
||||
silent = False,
|
||||
prompt = _never_prompt,
|
||||
)
|
||||
is True
|
||||
)
|
||||
@@ -0,0 +1,193 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
"""Regression tests for full finetuning precision on no-bf16 GPUs (V100/T4).
|
||||
|
||||
Full finetuning upcasts trainable weights to float32, so the model dtype is
|
||||
float32 (not bfloat16). The SFTTrainer mixed-precision template in
|
||||
unsloth/models/rl.py must then:
|
||||
- run the forward pass under float16 autocast for normal models,
|
||||
- keep FORCE_FLOAT32 models (Gemma3, gpt_oss, ...) in pure float32,
|
||||
- never select bf16 on hardware without bf16.
|
||||
|
||||
We execute the REAL template block extracted from rl.py source (no heavy unsloth
|
||||
import) against mocked inputs. See issue #4082.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
torch = pytest.importorskip("torch")
|
||||
|
||||
RL_PY = Path(__file__).resolve().parents[2] / "unsloth" / "models" / "rl.py"
|
||||
|
||||
|
||||
def _extract_mixed_precision_code() -> str:
|
||||
lines = RL_PY.read_text().split("\n")
|
||||
try:
|
||||
start = next(i for i, l in enumerate(lines) if "mixed_precision = (" in l)
|
||||
except StopIteration:
|
||||
pytest.skip("mixed_precision template not found in rl.py")
|
||||
body, k = [], start + 1
|
||||
while lines[k].strip() != ")":
|
||||
body.append(lines[k])
|
||||
k += 1
|
||||
return eval("(\n" + "\n".join(body) + "\n)") # only string literals + comments
|
||||
|
||||
|
||||
CODE = _extract_mixed_precision_code()
|
||||
|
||||
|
||||
def _restore(mapping, saved):
|
||||
"""Restore a dict-like to its saved snapshot: pop keys that were absent."""
|
||||
for k, v in saved.items():
|
||||
if v is None:
|
||||
mapping.pop(k, None)
|
||||
else:
|
||||
mapping[k] = v
|
||||
|
||||
|
||||
def _decide(dtype, *, bf16_supported, force_float32, full_finetuning, mixed_precision, fp16, bf16):
|
||||
"""Run the template block; return (args.fp16, args.bf16, ACCELERATE_MP, raised).
|
||||
|
||||
Stubs (sys.modules, env vars, torch.cuda.is_bf16_supported) are restored on
|
||||
exit so a decision can't leak into later tests in the same process.
|
||||
"""
|
||||
uzu = types.ModuleType("unsloth_zoo.utils")
|
||||
uzu._get_dtype = lambda x: x
|
||||
uzd = types.ModuleType("unsloth_zoo.device_type")
|
||||
uzd.device_is_bf16_supported = lambda: bf16_supported # device-aware signal stub
|
||||
|
||||
env_keys = (
|
||||
"UNSLOTH_FORCE_FLOAT32",
|
||||
"UNSLOTH_ENABLE_FULL_FINETUNING",
|
||||
"UNSLOTH_MIXED_PRECISION",
|
||||
"ACCELERATE_MIXED_PRECISION",
|
||||
)
|
||||
mod_keys = ("unsloth_zoo", "unsloth_zoo.utils", "unsloth_zoo.device_type")
|
||||
saved_env = {k: os.environ.get(k) for k in env_keys}
|
||||
saved_mods = {k: sys.modules.get(k) for k in mod_keys}
|
||||
orig_bf16 = torch.cuda.is_bf16_supported
|
||||
try:
|
||||
sys.modules.setdefault("unsloth_zoo", types.ModuleType("unsloth_zoo"))
|
||||
sys.modules["unsloth_zoo.utils"] = uzu
|
||||
sys.modules["unsloth_zoo.device_type"] = uzd
|
||||
for k in env_keys:
|
||||
os.environ.pop(k, None)
|
||||
os.environ["UNSLOTH_FORCE_FLOAT32"] = "1" if force_float32 else "0"
|
||||
os.environ["UNSLOTH_ENABLE_FULL_FINETUNING"] = "1" if full_finetuning else "0"
|
||||
os.environ["UNSLOTH_MIXED_PRECISION"] = mixed_precision
|
||||
torch.cuda.is_bf16_supported = lambda *a, **k: bf16_supported
|
||||
args = types.SimpleNamespace(fp16 = fp16, bf16 = bf16, mixed_precision = None)
|
||||
emb = types.SimpleNamespace(weight = types.SimpleNamespace(dtype = dtype))
|
||||
model = types.SimpleNamespace(
|
||||
config = types.SimpleNamespace(dtype = dtype, torch_dtype = dtype),
|
||||
get_input_embeddings = lambda: emb,
|
||||
)
|
||||
raised = None
|
||||
try:
|
||||
exec(CODE, {"torch": torch, "os": os}, {"args": args, "model": model})
|
||||
except TypeError:
|
||||
raised = "TypeError"
|
||||
return args.fp16, args.bf16, os.environ.get("ACCELERATE_MIXED_PRECISION"), raised
|
||||
finally:
|
||||
torch.cuda.is_bf16_supported = orig_bf16
|
||||
_restore(os.environ, saved_env)
|
||||
_restore(sys.modules, saved_mods)
|
||||
|
||||
|
||||
def test_v100_normal_fullft_fp16_explicit():
|
||||
# Normal model, full FT (weights upcast to float32), V100, fp16=True.
|
||||
fp16, bf16, amp, raised = _decide(
|
||||
torch.float32,
|
||||
bf16_supported = False,
|
||||
force_float32 = False,
|
||||
full_finetuning = True,
|
||||
mixed_precision = "float32",
|
||||
fp16 = True,
|
||||
bf16 = False,
|
||||
)
|
||||
assert raised is None
|
||||
assert (fp16, bf16) == (True, False) # float32 weights + fp16 forward
|
||||
|
||||
|
||||
def test_v100_normal_fullft_precision_unset():
|
||||
# Same, but user left precision unset -> must pick fp16, never bf16.
|
||||
fp16, bf16, amp, raised = _decide(
|
||||
torch.float32,
|
||||
bf16_supported = False,
|
||||
force_float32 = False,
|
||||
full_finetuning = True,
|
||||
mixed_precision = "float32",
|
||||
fp16 = False,
|
||||
bf16 = False,
|
||||
)
|
||||
assert raised is None
|
||||
assert (fp16, bf16) == (True, False)
|
||||
assert amp == "fp16"
|
||||
|
||||
|
||||
def test_force_float32_model_fullft_is_pure_float32():
|
||||
# FORCE_FLOAT32 model (Gemma3, gpt_oss, ...) in full FT -> pure float32, no autocast.
|
||||
fp16, bf16, amp, raised = _decide(
|
||||
torch.float32,
|
||||
bf16_supported = False,
|
||||
force_float32 = True,
|
||||
full_finetuning = True,
|
||||
mixed_precision = "float32",
|
||||
fp16 = True,
|
||||
bf16 = False,
|
||||
)
|
||||
assert raised is None
|
||||
assert (fp16, bf16) == (False, False)
|
||||
assert amp in (None, "no")
|
||||
|
||||
|
||||
def test_no_bf16_on_volta_in_auto_branch():
|
||||
# bf16 model dtype but no bf16 HW, precision unset -> fp16, never bf16.
|
||||
fp16, bf16, amp, raised = _decide(
|
||||
torch.bfloat16,
|
||||
bf16_supported = False,
|
||||
force_float32 = False,
|
||||
full_finetuning = False,
|
||||
mixed_precision = "float32",
|
||||
fp16 = False,
|
||||
bf16 = False,
|
||||
)
|
||||
assert bf16 is False
|
||||
|
||||
|
||||
def test_bf16_gpu_unchanged_auto_branch():
|
||||
# Regression guard: on a bf16 GPU, a float32 model with unset precision
|
||||
# still selects bf16 autocast (behavior must not change for bf16 hardware).
|
||||
fp16, bf16, amp, raised = _decide(
|
||||
torch.float32,
|
||||
bf16_supported = True,
|
||||
force_float32 = False,
|
||||
full_finetuning = True,
|
||||
mixed_precision = "float32",
|
||||
fp16 = False,
|
||||
bf16 = False,
|
||||
)
|
||||
assert raised is None
|
||||
assert (fp16, bf16) == (False, True)
|
||||
|
||||
|
||||
def test_genuine_bf16_model_with_fp16_still_raises():
|
||||
# A real bfloat16 model on bf16 HW with fp16 requested is a genuine mismatch.
|
||||
_, _, _, raised = _decide(
|
||||
torch.bfloat16,
|
||||
bf16_supported = True,
|
||||
force_float32 = False,
|
||||
full_finetuning = False,
|
||||
mixed_precision = "float32",
|
||||
fp16 = True,
|
||||
bf16 = False,
|
||||
)
|
||||
assert raised == "TypeError"
|
||||
@@ -0,0 +1,43 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
def test_vlm_lora_regex_respects_language_only_with_explicit_targets():
|
||||
from unsloth_zoo.peft_utils import get_peft_regex
|
||||
|
||||
class FakeVLM(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.language_model = torch.nn.Module()
|
||||
self.language_model.layers = torch.nn.ModuleList([torch.nn.Module()])
|
||||
self.language_model.layers[0].self_attn = torch.nn.Module()
|
||||
self.language_model.layers[0].self_attn.q_proj = torch.nn.Linear(4, 4)
|
||||
self.vision_tower = torch.nn.Module()
|
||||
self.vision_tower.vision_model = torch.nn.Module()
|
||||
self.vision_tower.vision_model.encoder = torch.nn.Module()
|
||||
self.vision_tower.vision_model.encoder.layers = torch.nn.ModuleList([torch.nn.Module()])
|
||||
self.vision_tower.vision_model.encoder.layers[0].self_attn = torch.nn.Module()
|
||||
self.vision_tower.vision_model.encoder.layers[0].self_attn.q_proj = torch.nn.Linear(
|
||||
4, 4
|
||||
)
|
||||
|
||||
regex = get_peft_regex(
|
||||
FakeVLM(),
|
||||
finetune_vision_layers = False,
|
||||
finetune_language_layers = True,
|
||||
finetune_attention_modules = True,
|
||||
finetune_mlp_modules = True,
|
||||
target_modules = ["q_proj"],
|
||||
)
|
||||
|
||||
assert re.search(regex, "language_model.layers.0.self_attn.q_proj")
|
||||
assert not re.search(regex, "vision_tower.vision_model.encoder.layers.0.self_attn.q_proj")
|
||||
|
||||
|
||||
def test_fast_vision_model_wraps_explicit_targets_when_layer_filters_are_used():
|
||||
source = Path("unsloth/models/vision.py").read_text()
|
||||
|
||||
assert "target_modules = get_peft_regex(" in source
|
||||
assert "target_modules = list(target_modules)" in source
|
||||
Reference in New Issue
Block a user