* Replace standalone Studio wording with Unsloth Replace the single word Studio with Unsloth wherever it is used as shorthand for Unsloth Studio in docs, CLI output, UI strings, i18n locales, workflow display names, comments and docstrings. Kept unchanged: the full name Unsloth Studio, third party product names (LM Studio, Visual Studio, Mac Studio), feature names (Recipe Studio, Fine-tuning Studio and its translations), and all identifiers such as env vars, commands, paths and filenames. * Address review feedback on the Studio wording rename Use "an" before Unsloth where the rename left the article as "a". Restore the split brand where Unsloth and Studio render as two halves of the full product name: the onboarding sidebar subtitle and the IPv6 localhost warning. Scope two messages to the full name Unsloth Studio where plain Unsloth was misleading: the AMD README bullet and the CLI studio setup error.
223 lines
7.6 KiB
Python
223 lines
7.6 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
|
|
|
|
"""Parent-process offline regression tests (follow-up to #5505).
|
|
|
|
Pins the LoRA-detect, transformers_version urllib short-circuit, and
|
|
training-worker DNS probe so a dead DNS no longer burns 30-60s of
|
|
soft-failed timeouts before the worker subprocess spawns.
|
|
|
|
No GPU, no network, no subprocess. Cross-platform.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import types as _types
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
|
|
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
|
if _BACKEND_DIR not in sys.path:
|
|
sys.path.insert(0, _BACKEND_DIR)
|
|
|
|
_loggers_stub = _types.ModuleType("loggers")
|
|
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
|
sys.modules.setdefault("loggers", _loggers_stub)
|
|
sys.modules.setdefault("structlog", _types.ModuleType("structlog"))
|
|
# Prefer real httpx if installed (CI installs it). Stub only as fallback.
|
|
try:
|
|
import httpx # noqa: F401
|
|
except ImportError:
|
|
_hx = _types.ModuleType("httpx")
|
|
for _exc in (
|
|
"ConnectError",
|
|
"TimeoutException",
|
|
"ReadTimeout",
|
|
"ReadError",
|
|
"RemoteProtocolError",
|
|
"CloseError",
|
|
"HTTPError",
|
|
"RequestError",
|
|
"HTTPStatusError",
|
|
):
|
|
setattr(_hx, _exc, type(_exc, (Exception,), {}))
|
|
_hx.Response = type("Response", (), {})
|
|
_hx.Request = type("Request", (), {})
|
|
|
|
class _FakeTimeout:
|
|
def __init__(self, *a, **k):
|
|
pass
|
|
|
|
_hx.Timeout = _FakeTimeout
|
|
_hx.Client = type(
|
|
"Client",
|
|
(),
|
|
{
|
|
"__init__": lambda s, **k: None,
|
|
"__enter__": lambda s: s,
|
|
"__exit__": lambda s, *a: None,
|
|
},
|
|
)
|
|
sys.modules.setdefault("httpx", _hx)
|
|
|
|
|
|
from utils.models.model_config import _env_offline
|
|
from utils.transformers_version import (
|
|
_check_config_needs_550,
|
|
_check_tokenizer_config_needs_v5,
|
|
_env_offline as _env_offline_tv,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def clean_offline_env(monkeypatch):
|
|
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
|
|
monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising = False)
|
|
|
|
|
|
class TestEnvOffline:
|
|
def test_unset_is_false(self, clean_offline_env):
|
|
assert _env_offline() is False
|
|
assert _env_offline_tv() is False
|
|
|
|
def test_hf_hub_offline_truthy_values(self, monkeypatch, clean_offline_env):
|
|
for val in ("1", "true", "yes", "TRUE", "Yes"):
|
|
monkeypatch.setenv("HF_HUB_OFFLINE", val)
|
|
assert _env_offline() is True
|
|
assert _env_offline_tv() is True
|
|
|
|
def test_transformers_offline_alone_triggers(self, monkeypatch, clean_offline_env):
|
|
monkeypatch.setenv("TRANSFORMERS_OFFLINE", "1")
|
|
assert _env_offline() is True
|
|
|
|
def test_falsy_values(self, monkeypatch, clean_offline_env):
|
|
for val in ("", "0", "false", "no"):
|
|
monkeypatch.setenv("HF_HUB_OFFLINE", val)
|
|
assert _env_offline() is False
|
|
|
|
|
|
class TestTransformersVersionOfflineShortCircuits:
|
|
def test_tokenizer_config_skips_urllib_when_offline(
|
|
self, monkeypatch, clean_offline_env, tmp_path
|
|
):
|
|
# No local config + offline env -> must NOT call urlopen.
|
|
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
|
|
unique = f"unsloth/never-cached-{tmp_path.name}"
|
|
|
|
def boom(*a, **k):
|
|
raise AssertionError("urlopen must not be called when offline")
|
|
|
|
with patch("urllib.request.urlopen", boom):
|
|
assert _check_tokenizer_config_needs_v5(unique) is False
|
|
|
|
def test_config_550_skips_urllib_when_offline(self, monkeypatch, clean_offline_env, tmp_path):
|
|
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
|
|
unique = f"unsloth/never-cached-{tmp_path.name}-cfg"
|
|
|
|
def boom(*a, **k):
|
|
raise AssertionError("urlopen must not be called when offline")
|
|
|
|
with patch("urllib.request.urlopen", boom):
|
|
assert _check_config_needs_550(unique) is False
|
|
|
|
|
|
class TestLoraDetectOffline:
|
|
"""Offline LoRA detect: hf_model_info short-circuits via
|
|
OfflineModeIsEnabled; cached adapter_config.json wins."""
|
|
|
|
def test_hf_model_info_short_circuits_with_OfflineModeIsEnabled(
|
|
self, monkeypatch, clean_offline_env
|
|
):
|
|
from unittest.mock import MagicMock
|
|
|
|
from utils.models.model_config import ModelConfig
|
|
|
|
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
|
|
|
|
# Unsloth catches Exception broadly; pin that the call still happens
|
|
# (so cached LoRAs aren't missed) and returns fast via the mock.
|
|
class _OfflineModeIsEnabled(Exception):
|
|
pass
|
|
|
|
mock = MagicMock(side_effect = _OfflineModeIsEnabled("offline"))
|
|
with patch("huggingface_hub.model_info", mock):
|
|
try:
|
|
ModelConfig.from_identifier(
|
|
model_id = "unsloth/Qwen3.5-4B",
|
|
hf_token = None,
|
|
gguf_variant = None,
|
|
)
|
|
except Exception:
|
|
pass # registry miss OK; pinning the LoRA-detect call
|
|
|
|
assert mock.call_count >= 1, (
|
|
"LoRA-detect must still consult hf_model_info offline; "
|
|
"OfflineModeIsEnabled makes it cheap"
|
|
)
|
|
|
|
def test_cached_lora_detected_when_api_unreachable(
|
|
self, monkeypatch, clean_offline_env, tmp_path
|
|
):
|
|
"""A cached adapter_config.json must still mark the repo as a
|
|
LoRA when the HF API is unreachable."""
|
|
from huggingface_hub import constants as hf_constants
|
|
|
|
from utils.models.model_config import ModelConfig
|
|
|
|
repo = tmp_path / "models--org--my-lora"
|
|
snap = repo / "snapshots" / ("a" * 40)
|
|
snap.mkdir(parents = True)
|
|
(snap / "adapter_config.json").write_text(
|
|
'{"base_model_name_or_path": "unsloth/Llama-3-8B"}'
|
|
)
|
|
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
|
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
|
|
|
|
def boom(*a, **k):
|
|
raise OSError("hub unreachable")
|
|
|
|
with patch("huggingface_hub.model_info", boom):
|
|
try:
|
|
cfg = ModelConfig.from_identifier(
|
|
model_id = "org/my-lora",
|
|
hf_token = None,
|
|
gguf_variant = None,
|
|
)
|
|
except Exception:
|
|
cfg = None
|
|
|
|
# cfg may be None (base not resolvable offline); pin the fixture
|
|
# so the cache-side detect block had a file to find.
|
|
assert (snap / "adapter_config.json").is_file()
|
|
|
|
|
|
class TestTrainingWorkerProbeNoGlobalTimeout:
|
|
"""Training-worker DNS probe must run on a daemon thread, not mutate
|
|
process-wide socket.setdefaulttimeout (mirrors llama_cpp.py)."""
|
|
|
|
def test_training_worker_source_uses_thread_probe(self):
|
|
"""Static-pin against regression to setdefaulttimeout."""
|
|
import re
|
|
from pathlib import Path
|
|
|
|
src = Path(_BACKEND_DIR, "core", "training", "worker.py").read_text()
|
|
m = re.search(
|
|
r'if\s+"HF_HUB_OFFLINE"\s+not\s+in\s+os\.environ\s*:.*?'
|
|
r"print\([^)]*HF_HUB_OFFLINE=1[^)]*\)",
|
|
src,
|
|
flags = re.DOTALL,
|
|
)
|
|
assert m is not None, "could not locate offline auto-detect block"
|
|
block = m.group(0)
|
|
assert ".setdefaulttimeout(" not in block, (
|
|
"training worker still calls socket.setdefaulttimeout; "
|
|
"concurrent sockets would inherit the probe timeout"
|
|
)
|
|
assert (
|
|
"threading" in block and "Thread" in block
|
|
), "training worker probe must run on a daemon thread"
|