unsloth/studio/backend/tests/test_hf_xet_fallback.py
Daniel Han 41dd95ea0a
Studio: don't pin transformers before the training worker activates the 5.x sidecar (#6968)
* Studio: keep transformers off sys.modules until the training worker activates the sidecar

The training worker (core/training/worker.py:run_training_process) decides the per-worker
Xet env flip during preflight by importing utils/hf_xet_fallback.py, which eagerly imported
unsloth_zoo at module load. unsloth_zoo's __init__ imports transformers, so the default
transformers 4.57.x was cached in sys.modules before activate_transformers_for_subprocess
prepended the 5.x sidecar to sys.path. Since activation only edits sys.path, the already
cached module won, and 5.x models failed to load their tokenizer or config:
  - Qwen3.5 / GLM-4.7 (tokenizer_class TokenizersBackend): "Tokenizer class TokenizersBackend
    does not exist or is not currently imported."
  - gemma-4: "... is not supported yet in transformers==4.57.6."

Fix: load the shared unsloth_zoo backend lazily (only when a heavy download helper is first
used, which is after activation). child_should_disable_xet and the DEFAULT_* constants are
defined locally so importing the shim stays light. The download wrappers, the DownloadStallError
class, start_watchdog and get_hf_download_state resolve the shared backend on first use, and the
degraded no-unsloth_zoo fallback is preserved.

Tests:
  - test_hf_xet_fallback.py: existing suite kept green via the restored _shared_* seam; the
    GPU-init retry test now triggers the lazy load explicitly; new guard asserts importing
    child_should_disable_xet does not import transformers/unsloth_zoo.
  - test_training_worker_import_discipline.py: new invariant test that the worker preflight
    imports leave transformers unimported, so this class of regression cannot return silently.
    Runs in studio-backend-ci (CPU only, no network/GPU/weights).

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

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

* Studio: CPU-only guard that activation switches transformers to the model's sidecar version

Adds test_worker_activates_correct_transformers.py: runs the real worker preflight
(from utils.hf_xet_fallback import child_should_disable_xet) plus the real tier
detection and activate_transformers_for_subprocess for a transformers-5.x model
(Qwen3.5, tier 530), then asserts the in-process transformers actually switched to
the 5.x sidecar. A stale pre-activation import leaves 4.57.x pinned and fails the
assertion, which is exactly the TokenizersBackend regression (#6951).

Self-contained CUDA spoof (mirrors tests/_zoo_aggressive_cuda_spoof.py) forces
unsloth_zoo down its full, transformers-importing init path on a GPU-less runner;
without it unsloth_zoo degrades and never preloads transformers, masking the bug.
A one-line stub sidecar stands in for the 5.x venv, so no GPU, network, weights, or
real sidecar are needed. Passes on this fix, fails on buggy main.

* Studio: load the repo's canonical CUDA spoof in the correct-version guard

Load tests/_zoo_aggressive_cuda_spoof.py (the committed spoof the consolidated CI
already relies on) as the single source of truth so the guard matches CI and stays
robust on a CPU-only torch wheel, where a partial hand-rolled spoof could miss a
torch.cuda call and let the unsloth_zoo import raise (masking the bug). Falls back to
a minimal inline spoof for a standalone studio checkout. Verified: passes on this fix,
fails on buggy main, and the fallback path passes when the spoof file is absent.

* Studio: declare the lazily-resolved xet names so ruff F822 stays green

DownloadStallError, start_watchdog and get_hf_download_state are provided via the
module __getattr__ (PEP 562), so ruff F822 flagged them as undefined names in __all__
and the Source-lint / pre-commit checks went red. Add annotation-only declarations
(no value bound, so __getattr__ still resolves them lazily to the shared unsloth_zoo
backend) to mark them defined for the linter while keeping F822 active for the rest
of __all__.

* Studio: tighten comments on the sidecar-activation fix and its tests

* Studio: mirror the new MLX-dispatch preflight import in the import-discipline guard

The worker preflight now also runs 'from core.training.training import
is_apple_silicon_training_platform, should_use_mlx_training_backend' before it
activates the transformers sidecar. Add that import (guarded) to the guard's
preflight snippet so the invariant test stays a faithful mirror: a future change
that makes core.training.training pull transformers/unsloth_zoo eagerly would then
be caught too. Verified clean on the current tree (no leak).

---------

Co-authored-by: danielhanchen <unslothai@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-07-08 05:33:16 -07:00

369 lines
14 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for the Studio shim over the shared unsloth_zoo Xet -> HTTP fallback.
The transport-policy matrix is tested once in unsloth_zoo; here we assert only the
Studio seam: re-exporting the shared API and injecting the marker-aware
prepare_cache_for_transport on the HTTP retry. CPU-only, no network, no real subprocess.
"""
from __future__ import annotations
import sys
import types as _types
from pathlib import Path
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# Stub heavy/unavailable deps before importing the module under test. Use real structlog when present;
# a bare stub would break later modules that log at import time.
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
try:
import structlog # noqa: F401
except ImportError:
sys.modules["structlog"] = _types.ModuleType("structlog")
import huggingface_hub
try:
import unsloth_zoo.hf_xet_fallback as _shared_mod
shared = _shared_mod
except Exception: # noqa: BLE001 - still collect degraded-path tests when unsloth_zoo is unavailable
shared = None
import utils.hf_xet_fallback as xf
DL_REPO, FILE = "ztest/xet-dl", "model-Q4_K_XL.gguf"
def _requires_shared():
if shared is None:
pytest.skip("unsloth_zoo.hf_xet_fallback is not installed in this environment")
def test_shim_reexports_shared_api():
_requires_shared()
assert xf.DownloadStallError is shared.DownloadStallError
for name in (
"start_watchdog",
"get_hf_download_state",
"child_should_disable_xet",
"hf_hub_download_with_xet_fallback",
"snapshot_download_with_xet_fallback",
):
assert hasattr(xf, name), f"shim missing {name}"
def test_child_should_disable_xet_truth_table():
assert xf.child_should_disable_xet({"disable_xet": True}) is True
assert xf.child_should_disable_xet({"disable_xet": False}) is False
assert xf.child_should_disable_xet({}) is False
def test_shim_injects_studio_prepare_on_http_retry(monkeypatch):
"""A Xet stall retries over HTTP and the shim runs Studio's marker-aware
``prepare_cache_for_transport(..., 'http')`` before the retry."""
_requires_shared()
for var in ("UNSLOTH_DISABLE_XET", "UNSLOTH_STABLE_DOWNLOADS", "HF_HUB_DISABLE_XET"):
monkeypatch.delenv(var, raising = False)
monkeypatch.setattr(huggingface_hub, "try_to_load_from_cache", lambda *a, **k: None)
seen_disable_xet = []
def fake_attempt(
repo_id,
*,
kind,
params,
token,
repo_type,
disable_xet,
cancel_event,
stall_timeout,
interval,
grace_period,
on_status,
force_download = False,
):
seen_disable_xet.append(disable_xet)
return ("ok", "/cache/model.gguf") if disable_xet else ("stall", None)
monkeypatch.setattr(shared, "_run_download_attempt", fake_attempt)
prepared = []
monkeypatch.setattr(
"hub.utils.download_registry.prepare_cache_for_transport",
lambda repo_type, repo_id, mode, *a, **k: prepared.append((repo_type, repo_id, mode)),
)
out = xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
assert out == "/cache/model.gguf"
assert seen_disable_xet == [False, True] # Xet first, then HTTP
assert prepared == [("model", DL_REPO, "http")], "shim must run Studio's marker-aware prep"
def test_shim_snapshot_injects_studio_prepare(monkeypatch):
"""The snapshot wrapper forwards Studio's marker-aware prep, like the file wrapper."""
captured = {}
def fake_snapshot(repo_id, **kwargs):
captured["repo_id"] = repo_id
captured["prepare_for_http_fn"] = kwargs.get("prepare_for_http_fn")
return "/tmp/snap-dir"
monkeypatch.setattr(xf, "_shared_snapshot_download_with_xet_fallback", fake_snapshot)
out = xf.snapshot_download_with_xet_fallback("org/model")
assert out == "/tmp/snap-dir"
assert captured["repo_id"] == "org/model"
assert captured["prepare_for_http_fn"] is xf._studio_prepare_for_http
def test_degrades_gracefully_without_shared_helper(monkeypatch):
"""On an older unsloth_zoo lacking the shared helper, the shim still imports (Studio
boots) and exposes stub API doing plain HF downloads with the watchdog disabled."""
import importlib
class _BlockShared:
def find_spec(
self,
name,
path = None,
target = None,
):
if name == "unsloth_zoo.hf_xet_fallback":
raise ModuleNotFoundError(f"No module named '{name}'", name = name)
return None
finder = _BlockShared()
saved_shared = sys.modules.pop("unsloth_zoo.hf_xet_fallback", None)
saved_shim = sys.modules.pop("utils.hf_xet_fallback", None)
sys.meta_path.insert(0, finder)
try:
degraded = importlib.import_module("utils.hf_xet_fallback")
# Boots without raising and mirrors the shared API surface.
assert issubclass(degraded.DownloadStallError, RuntimeError)
assert degraded.child_should_disable_xet({"disable_xet": True}) is True
assert degraded.get_hf_download_state(["x"]) is None # unmeasurable
event = degraded.start_watchdog(repo_ids = ["x"], on_stall = lambda m: None)
assert hasattr(event, "set") and not event.is_set() # never fires
# Degraded mode still emits heartbeats so the inactivity deadline is not tripped.
import time as _time
beats = []
hb_stop = degraded.start_watchdog(
repo_ids = ["x"],
on_stall = lambda m: None,
on_heartbeat = beats.append,
interval = 0.02,
)
try:
deadline = _time.monotonic() + 2.0
while not beats and _time.monotonic() < deadline:
_time.sleep(0.02)
assert beats, "degraded watchdog emitted no heartbeat"
finally:
hb_stop.set()
# Downloads fall back to plain huggingface_hub (no watchdog, no crash).
called = {}
def _fake_snapshot(repo_id, **kwargs):
called["repo_id"] = repo_id
return "/snap-dir"
monkeypatch.setattr(huggingface_hub, "snapshot_download", _fake_snapshot)
assert degraded.snapshot_download_with_xet_fallback("org/model") == "/snap-dir"
assert called["repo_id"] == "org/model"
# Cancellation still holds: an already-set cancel_event aborts before the HF download.
import threading as _threading
cancelled = _threading.Event()
cancelled.set()
called.clear()
with pytest.raises(RuntimeError, match = "Cancelled"):
degraded.snapshot_download_with_xet_fallback("org/model", cancel_event = cancelled)
assert "repo_id" not in called, "degraded download ran despite cancellation"
finally:
sys.meta_path.remove(finder)
sys.modules.pop("utils.hf_xet_fallback", None)
if saved_shared is not None:
sys.modules["unsloth_zoo.hf_xet_fallback"] = saved_shared
if saved_shim is not None:
sys.modules["utils.hf_xet_fallback"] = saved_shim
def test_degrades_when_unsloth_zoo_entirely_absent():
"""When unsloth_zoo is absent entirely, the import raises
ModuleNotFoundError(name='unsloth_zoo') (top-level package). Guard that the shim still
degrades and does not re-raise, breaking every Studio import that pulls it in."""
import importlib
class _BlockZoo:
def find_spec(
self,
name,
path = None,
target = None,
):
# Whole package absent, so ModuleNotFoundError.name is the top-level 'unsloth_zoo'.
if name == "unsloth_zoo" or name.startswith("unsloth_zoo."):
raise ModuleNotFoundError("No module named 'unsloth_zoo'", name = "unsloth_zoo")
return None
finder = _BlockZoo()
saved = {
k: v
for k, v in list(sys.modules.items())
if k == "unsloth_zoo" or k.startswith("unsloth_zoo.")
}
for k in saved:
del sys.modules[k]
saved_shim = sys.modules.pop("utils.hf_xet_fallback", None)
sys.meta_path.insert(0, finder)
try:
degraded = importlib.import_module("utils.hf_xet_fallback")
# Boots without raising and exposes the stub API.
assert issubclass(degraded.DownloadStallError, RuntimeError)
assert degraded.get_hf_download_state(["x"]) is None
event = degraded.start_watchdog(repo_ids = ["x"], on_stall = lambda m: None)
assert hasattr(event, "set") and not event.is_set()
finally:
sys.meta_path.remove(finder)
sys.modules.pop("utils.hf_xet_fallback", None)
sys.modules.update(saved)
if saved_shim is not None:
sys.modules["utils.hf_xet_fallback"] = saved_shim
def test_degrades_when_shared_helper_import_raises_importerror():
"""unsloth_zoo can be installed yet fail to import when torch is missing (llama.cpp/GGUF-only
Studio), raising ImportError not ModuleNotFoundError. The shim must degrade for that too."""
import importlib
class _BlockWithImportError:
def find_spec(
self,
name,
path = None,
target = None,
):
if name == "unsloth_zoo.hf_xet_fallback":
# Mirror a torch-less install: a plain ImportError with no .name.
raise ImportError("Unsloth: Pytorch is not installed.")
return None
finder = _BlockWithImportError()
saved_shared = sys.modules.pop("unsloth_zoo.hf_xet_fallback", None)
saved_zoo = sys.modules.pop("unsloth_zoo", None)
saved_shim = sys.modules.pop("utils.hf_xet_fallback", None)
sys.meta_path.insert(0, finder)
try:
degraded = importlib.import_module("utils.hf_xet_fallback")
assert issubclass(degraded.DownloadStallError, RuntimeError)
assert degraded.get_hf_download_state(["x"]) is None
event = degraded.start_watchdog(repo_ids = ["x"], on_stall = lambda m: None)
assert hasattr(event, "set") and not event.is_set()
finally:
sys.meta_path.remove(finder)
sys.modules.pop("utils.hf_xet_fallback", None)
if saved_shared is not None:
sys.modules["unsloth_zoo.hf_xet_fallback"] = saved_shared
if saved_zoo is not None:
sys.modules["unsloth_zoo"] = saved_zoo
if saved_shim is not None:
sys.modules["utils.hf_xet_fallback"] = saved_shim
def test_retries_under_light_gpu_init_when_import_fails(monkeypatch):
"""GPU detection in unsloth_zoo's __init__ raises NotImplementedError on a GPU-less host. The shim
retries under UNSLOTH_ZOO_DISABLE_GPU_INIT=1, restores the env, and degrades if the retry fails.
The backend loads lazily (first use of a heavy helper), so this triggers the load explicitly
before asserting the retry/degrade behavior."""
import importlib
import os
monkeypatch.delenv("UNSLOTH_ZOO_DISABLE_GPU_INIT", raising = False)
seen_env = []
class _GpuGatedBlocker:
def find_spec(
self,
name,
path = None,
target = None,
):
# Crash is in unsloth_zoo's __init__, so intercept "unsloth_zoo" itself (the parent).
if name == "unsloth_zoo":
# Record the env each attempt sees; raise the no-GPU error both times so the shim
# degrades.
seen_env.append(os.environ.get("UNSLOTH_ZOO_DISABLE_GPU_INIT"))
raise NotImplementedError("Unsloth cannot find any torch accelerator")
return None
finder = _GpuGatedBlocker()
saved = {
k: v
for k, v in list(sys.modules.items())
if k == "unsloth_zoo" or k.startswith("unsloth_zoo.")
}
for k in saved:
del sys.modules[k]
saved_shim = sys.modules.pop("utils.hf_xet_fallback", None)
sys.meta_path.insert(0, finder)
try:
degraded = importlib.import_module("utils.hf_xet_fallback")
# Import is light (lazy backend); unsloth_zoo not loaded yet.
assert seen_env == [], seen_env
# First use of a heavy helper triggers the load (attempt without the light env, then a retry
# with it set); accessing DownloadStallError drives it via __getattr__.
stall_error = degraded.DownloadStallError
assert seen_env == [None, "1"], seen_env
# Both attempts raised -> Studio still boots in degraded mode.
assert issubclass(stall_error, RuntimeError)
# The env override must not leak past the load.
assert os.environ.get("UNSLOTH_ZOO_DISABLE_GPU_INIT") is None
finally:
sys.meta_path.remove(finder)
sys.modules.pop("utils.hf_xet_fallback", None)
sys.modules.update(saved)
if saved_shim is not None:
sys.modules["utils.hf_xet_fallback"] = saved_shim
def test_importing_child_should_disable_xet_stays_light(monkeypatch):
"""Regression guard for the stale-transformers-sidecar bug: importing the shim (and
``child_should_disable_xet``) must NOT pull in ``transformers``/``unsloth_zoo``. The worker calls
this at startup to decide the Xet env flip BEFORE activating the sidecar; an eager import here
would cache the default transformers 4.57.x in sys.modules, defeating the sidecar sys.path prepend
and breaking 5.x models (Qwen3.5/GLM/gemma-4)."""
import importlib
for name in [
m
for m in list(sys.modules)
if m == "transformers"
or m.startswith("transformers.")
or m == "unsloth_zoo"
or m.startswith("unsloth_zoo.")
or m == "utils.hf_xet_fallback"
]:
monkeypatch.delitem(sys.modules, name, raising = False)
mod = importlib.import_module("utils.hf_xet_fallback")
# The lightweight decision works without the heavy backend.
assert mod.child_should_disable_xet({"disable_xet": True}) is True
assert mod.child_should_disable_xet({}) is False
# And nothing heavy was imported as a side effect.
assert "transformers" not in sys.modules, "importing the shim must not import transformers"
assert "unsloth_zoo" not in sys.modules, "importing the shim must not import unsloth_zoo"