unsloth/studio/backend/tests/test_transformers_version.py
Daniel Han 2a05426adb
Auto-install SSM kernels (causal-conv1d, mamba-ssm) for inference loads (#6535)
* Auto-install SSM kernels (causal-conv1d, mamba-ssm) for inference loads

Mamba/SSM hybrids (Nemotron-H/Nano, Falcon-H1, Granite-4.0-H, ...) lazily import
mamba_ssm / causal_conv1d during from_pretrained, so loading them for chat failed
with 'mamba-ssm is required by the Mamba model but cannot be imported'. The training
worker already wheel-first installs these before a fine-tune; the inference worker
did not. Add utils/ssm_runtime.ensure_ssm_runtime and call it from the inference load
path so the same models load for inference. Training worker is untouched; a drift
test keeps the shared detection and pinned versions in lockstep.

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

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

* ssm_runtime: invalidate import caches, skip MLX, cover LoRA base

- Invalidate importlib finder caches in _is_importable and after a successful
  wheel install, so a kernel installed earlier in this same process is actually
  importable when the modeling code lazy-imports it during from_pretrained.
- Skip the SSM kernel install entirely on the MLX (Apple Silicon) load path:
  these are CUDA/ROCm Torch kernels with no MLX use and no macOS prebuilt wheel,
  so the source build would fail before the MLX backend loads the model.
- For LoRA loads, also run detection over the resolved base model, since an
  adapter id like 'me/my-lora' won't match the SSM heuristics but its SSM base
  (Nemotron-H, ...) is what needs the kernels.

Adds tests for cache invalidation and the MLX-skip / LoRA-base worker wiring.

* Tighten SSM autoinstall comments

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

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

* ssm_runtime: verify wheel imports, HIP-aware source build, build heartbeat

Address review feedback:
- Verify a prebuilt wheel actually imports before trusting it; a CUDA/ABI-mismatched
  wheel now falls back to a source build instead of returning success and failing later
  with the cryptic lazy-import error.
- HIP-aware source build: require hipcc on ROCm, inject clang --gcc-install-dir, and use
  the 1800s timeout, mirroring the training worker (ROCm has no prebuilt wheel).
- Emit a status heartbeat every 60s during the source build so a long (ROCm) build does
  not trip the orchestrator's 300s inactivity timeout.

Tests cover the wheel-not-importable fallback and the missing-hipcc ROCm bail.

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

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

* Make causal-conv1d best-effort and harden the SSM source build

- causal-conv1d is a fast path: models that merely want it (Qwen3-Next, LFM2)
  fall back to torch, so a failed install must not reject an otherwise loadable
  chat model on Windows/CPU/macOS or an ABI without a wheel. Only a true SSM
  model's mamba-ssm requirement stays fatal, matching the training worker which
  treats causal-conv1d as best-effort.
- The source build is reached only when not importable, including a wheel that
  installed but failed to import; add --reinstall/--force-reinstall so it
  replaces the broken install instead of no-opping as already satisfied.
- Add --no-cache to the ROCm uv source build to avoid reusing stale artifacts
  from a partial HIP build, mirroring the training worker.

* Address review: install SSM kernels before transformers, harden import + Windows

Codex:
- Install the SSM kernels before importing transformers. run_inference_process
  imported core.inference.inference (which imports unsloth/transformers) before the
  load, and a sidecar transformers can evaluate its optional-backend gates against
  the import state; installing causal_conv1d/mamba_ssm afterwards left those gates
  unsatisfied and a Nemotron/Falcon/Granite load still failed with "mamba-ssm is
  required". The initial model's kernels are now installed in run_inference_process
  before the ML import, via a shared _ensure_ssm_kernels helper; _handle_load keeps
  calling it (idempotent) for a LoRA's base and for later in-process loads.
- _is_importable now treats any import failure as "not importable", not only
  ImportError. An ABI-incompatible native kernel (undefined symbol after a torch/CUDA
  upgrade) raises OSError/RuntimeError; letting those escape reported
  ssm_runtime_install_failed instead of falling back to reinstall/source build.
- Skip causal-conv1d on Windows (no prebuilt wheel), mirroring the training worker.
  A causal-conv1d-only model (Qwen3-Next/LFM2) no longer drops a chat load into a
  multi-minute untimed source build; it uses the torch fallback. mamba-ssm is still
  attempted for true SSM hybrids.

Tests: test_ssm_runtime.py +5 (broken-kernel exceptions read as not-importable;
causal-conv1d skipped on win32 while mamba-ssm still installs). 36 passed.

* Trim comments to be more succinct

* Run security gates before installing SSM kernels

The SSM kernel auto-install is name-based (model_is_ssm is a substring match, no
config fetch), so a model id merely containing an SSM substring triggered a
native-package install (possibly a slow source build) before the malware and
remote-code consent gates ran. Extract those gates into _run_security_gates and
call it before the kernel install in both the pre-import path of
run_inference_process and in _handle_load, so a blocked or nonexistent model is
refused before any build. The gates are metadata-only and do not import
transformers, so they are safe to run before the pre-import install.

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

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

* Resolve remote LoRA bases before importing transformers

_resolve_base_model only reads a local adapter_config.json, so a remote LoRA
adapter whose own id has no SSM substring but whose base is a Nemotron/Falcon/
Granite model had its base discovered only by ModelConfig in _handle_load, after
transformers was imported and its optional-backend availability snapshotted, so
the SSM kernel install there was too late. Add _remote_lora_base, a metadata-only
adapter_config.json fetch (no huggingface_hub / transformers import), and use it
in the pre-import path so the base is gated and its kernels pre-installed.

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

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

* Gate only loaded roots, tier on the resolved base, read offline LoRA cache

Three follow-ups to the pre-import resolution:

- The security gate reused the SSM target list, which for a local full fine-tune
  includes the config.json-recorded base. That base is never loaded, so scanning
  it could falsely block a safe local checkpoint. Gate only the model plus a
  genuine LoRA base (matching _handle_load's mc.is_lora), separate from the
  broader SSM-install list.

- Tier activation ran on the raw adapter id, so a remote LoRA whose base needs a
  sidecar transformers version imported the default and failed. Resolve the base
  once up front and activate on it.

- _remote_lora_base bailed on offline before checking the hub cache, missing a
  cached adapter's base. Read the cached adapter_config.json when offline or when
  the fetch fails.

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

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

* Keep the pre-import gate transformers-free; harden remote LoRA resolution

The pre-import security gate called security_load_subdirs, which imports
model_config and thus transformers, snapshotting optional-backend availability
before the SSM kernels are installed and defeating the ordering. Add
compute_subdirs to _run_security_gates and pass False in the preflight so it scans
from the root only (transformers-free); _handle_load still runs the authoritative
gate with full subdir scoping after the import.

_remote_lora_base now skips existing local relative paths (is_local_path) so a
checkpoint like outputs/run1 is never treated as a Hub repo, and distinguishes a
definitive 404 (not a LoRA -> None) from transient/offline failures (read the
cache), so a repo that is now a full model no longer resolves a stale cached base.

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

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

* Probe a real model id for SSM kernels; respect HF_ENDPOINT

model_is_ssm is a substring match, so an arbitrary name could false-match and
force a mamba-ssm install that fails the load for a non-SSM model:
- a LoRA adapter id like user/falcon-h1-lora (the SSM-relevant code is the base's);
- a local checkpoint under an SSM-named parent dir, e.g. /runs/falcon-h1/llama-ckpt.

Add ssm_probe_identifier, which resolves the base (or a bare local checkpoint's
basename) and feed that to ensure_ssm_runtime from both the pre-import path and
_handle_load, so detection runs against a real model id, never an adapter id or
parent folders.

_remote_lora_base now honors HF_ENDPOINT so enterprise/mirror deployments resolve
the adapter base instead of always hitting huggingface.co.

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

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

* Tighten comments in the pre-import SSM gate/install path

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <michaelhan2050@gmail.com>
2026-06-22 04:48:29 -07:00

1265 lines
52 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 transformers version detection with local checkpoint fallbacks."""
import json
import logging
import os
import pytest
from pathlib import Path
from unittest.mock import patch
# ---------------------------------------------------------------------------
# The studio backend uses relative-style imports (``from utils.…``), so
# add the backend directory to *sys.path* if not already present.
# ---------------------------------------------------------------------------
import sys
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# Stub the custom logger before import so ``from loggers import
# get_logger`` doesn't fail.
import types as _types
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
from utils.transformers_version import (
_resolve_base_model,
_remote_lora_base,
_check_tokenizer_config_needs_v5,
_check_config_needs_510,
_check_config_needs_550,
_config_needs_510,
_nemotron_h_needs_mlp_support,
_config_json_from_hf_cache,
_load_config_json,
_higher_tier,
_config_json_cache,
_tokenizer_class_cache,
_config_needs_510_cache,
_config_needs_550_cache,
needs_transformers_5,
get_transformers_tier,
activate_transformers_for_subprocess,
_venv_dir_is_valid,
_ensure_venv_dir,
)
@pytest.fixture(autouse = True)
def _capturable_logger(monkeypatch):
"""Make the ``caplog`` assertions independent of test collection order.
The ``sys.modules.setdefault("loggers", ...)`` stub above only installs the
stdlib-logger stub when ``loggers`` has not been imported yet. In a full
backend pytest run another module (e.g. ``test_log_filter_no_truncation``,
collected earlier) imports the real ``loggers`` first, so the stub is a
no-op and ``transformers_version.logger`` ends up a structlog/stdout logger
that ``caplog`` cannot see -- the tier/activation/install log assertions
would then fail even though the line was emitted. Bind a real stdlib logger
for the duration of each test so the module logs through ``logging`` and
``caplog`` captures them regardless of import order.
"""
monkeypatch.setattr(
"utils.transformers_version.logger",
logging.getLogger("utils.transformers_version"),
)
# ---------------------------------------------------------------------------
# _resolve_base_model — config.json fallback
# ---------------------------------------------------------------------------
class TestResolveBaseModel:
"""Tests for _resolve_base_model() local config fallbacks."""
def test_adapter_config_takes_priority(self, tmp_path: Path):
"""adapter_config.json should be preferred over config.json."""
adapter_cfg = {"base_model_name_or_path": "meta-llama/Llama-3-8B"}
config_cfg = {"_name_or_path": "different/model"}
(tmp_path / "adapter_config.json").write_text(json.dumps(adapter_cfg))
(tmp_path / "config.json").write_text(json.dumps(config_cfg))
result = _resolve_base_model(str(tmp_path))
assert result == "meta-llama/Llama-3-8B"
def test_config_json_fallback_model_name(self, tmp_path: Path):
"""config.json model_name should resolve when no adapter_config."""
config_cfg = {"model_name": "Qwen/Qwen3.5-9B"}
(tmp_path / "config.json").write_text(json.dumps(config_cfg))
result = _resolve_base_model(str(tmp_path))
assert result == "Qwen/Qwen3.5-9B"
def test_config_json_fallback_name_or_path(self, tmp_path: Path):
"""config.json _name_or_path should resolve as secondary fallback."""
config_cfg = {"_name_or_path": "Qwen/Qwen3.5-9B"}
(tmp_path / "config.json").write_text(json.dumps(config_cfg))
result = _resolve_base_model(str(tmp_path))
assert result == "Qwen/Qwen3.5-9B"
def test_model_name_takes_priority_over_name_or_path(self, tmp_path: Path):
"""model_name should be preferred over _name_or_path."""
config_cfg = {
"model_name": "Qwen/Qwen3.5-9B",
"_name_or_path": "some/other-model",
}
(tmp_path / "config.json").write_text(json.dumps(config_cfg))
result = _resolve_base_model(str(tmp_path))
assert result == "Qwen/Qwen3.5-9B"
def test_config_json_skips_self_referencing(self, tmp_path: Path):
"""config.json should be ignored if model_name == the checkpoint path."""
config_cfg = {"model_name": str(tmp_path)}
(tmp_path / "config.json").write_text(json.dumps(config_cfg))
result = _resolve_base_model(str(tmp_path))
# Falls through; does not return the self-referencing path.
assert result == str(tmp_path)
def test_no_config_files(self, tmp_path: Path):
"""Returns original name when no config files are present."""
result = _resolve_base_model(str(tmp_path))
assert result == str(tmp_path)
def test_plain_hf_id_passthrough(self):
"""Plain HuggingFace model IDs pass through unchanged."""
result = _resolve_base_model("meta-llama/Llama-3-8B")
assert result == "meta-llama/Llama-3-8B"
class TestRemoteLoraBase:
"""_remote_lora_base reads a remote adapter's base from its Hub adapter_config.json."""
@staticmethod
def _resp(cfg: dict):
class _Resp:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return json.dumps(cfg).encode()
return _Resp()
def test_fetches_base_from_remote_adapter_config(self, monkeypatch):
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
cfg = {"base_model_name_or_path": "nvidia/NVIDIA-Nemotron-3-Nano-4B"}
with patch("urllib.request.urlopen", return_value = self._resp(cfg)):
assert (
_remote_lora_base("someuser/my-nemotron-lora") == "nvidia/NVIDIA-Nemotron-3-Nano-4B"
)
def test_local_or_noncanonical_returns_none(self):
assert _remote_lora_base("/local/dir/adapter") is None
assert _remote_lora_base("plainname") is None
def test_respects_hf_endpoint(self, monkeypatch):
# Enterprise mirror: the fetch must target HF_ENDPOINT, not hardcoded huggingface.co.
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
monkeypatch.setenv("HF_ENDPOINT", "https://hf.mirror.internal")
seen = {}
def fake_urlopen(req, timeout = 10):
seen["url"] = req.full_url
return self._resp({"base_model_name_or_path": "org/base"})
with patch("urllib.request.urlopen", side_effect = fake_urlopen):
assert _remote_lora_base("user/adapter") == "org/base"
assert seen["url"].startswith("https://hf.mirror.internal/user/adapter/raw/main/")
@staticmethod
def _seed_adapter_cache(
hub: Path,
repo_id: str,
base: str,
commit: str = "deadbeef",
):
repo = hub / ("models--" + repo_id.replace("/", "--"))
snap = repo / "snapshots" / commit
snap.mkdir(parents = True)
(snap / "adapter_config.json").write_text(json.dumps({"base_model_name_or_path": base}))
(repo / "refs").mkdir(parents = True)
(repo / "refs" / "main").write_text(commit)
def test_offline_reads_base_from_hf_cache(self, tmp_path: Path, monkeypatch):
self._seed_adapter_cache(tmp_path, "user/cached-lora", "nvidia/Nemotron-H-8B")
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
with patch("urllib.request.urlopen") as mock_url:
assert _remote_lora_base("user/cached-lora") == "nvidia/Nemotron-H-8B"
mock_url.assert_not_called() # offline: cache only, no network
def test_fetch_failure_falls_back_to_cache(self, tmp_path: Path, monkeypatch):
self._seed_adapter_cache(tmp_path, "user/cached-lora", "nvidia/Nemotron-H-8B")
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
with patch("urllib.request.urlopen", side_effect = OSError("boom")):
assert _remote_lora_base("user/cached-lora") == "nvidia/Nemotron-H-8B"
def test_offline_uncached_makes_no_request(self, tmp_path: Path, monkeypatch):
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
with patch("urllib.request.urlopen") as mock_url:
assert _remote_lora_base("org/adapter") is None
mock_url.assert_not_called()
def test_non_adapter_repo_returns_none(self, tmp_path: Path, monkeypatch):
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
with patch("urllib.request.urlopen", side_effect = OSError("boom")):
assert _remote_lora_base("org/not-an-adapter") is None
def test_existing_relative_path_not_treated_as_repo(self, monkeypatch):
# An existing one-slash relative path (e.g. outputs/run1) is a local checkpoint, not
# a Hub repo: no request, no risk of matching an unrelated remote/cached adapter.
import utils.paths as paths
monkeypatch.setattr(paths, "is_local_path", lambda p: True)
with patch("urllib.request.urlopen") as mock_url:
assert _remote_lora_base("outputs/run1") is None
mock_url.assert_not_called()
def test_404_returns_none_not_stale_cache(self, tmp_path: Path, monkeypatch):
import urllib.error
# The repo is now a full model (adapter_config.json 404s) but a stale LoRA snapshot is
# cached: a definitive 404 must return None, not the stale base.
self._seed_adapter_cache(tmp_path, "user/was-a-lora", "old/base")
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
err = urllib.error.HTTPError("url", 404, "Not Found", {}, None)
with patch("urllib.request.urlopen", side_effect = err):
assert _remote_lora_base("user/was-a-lora") is None
def test_transient_http_error_falls_back_to_cache(self, tmp_path: Path, monkeypatch):
import urllib.error
self._seed_adapter_cache(tmp_path, "user/cached-lora", "nvidia/Nemotron-H-8B")
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
err = urllib.error.HTTPError("url", 503, "Service Unavailable", {}, None)
with patch("urllib.request.urlopen", side_effect = err):
assert _remote_lora_base("user/cached-lora") == "nvidia/Nemotron-H-8B"
# ---------------------------------------------------------------------------
# _check_tokenizer_config_needs_v5 — local file check
# ---------------------------------------------------------------------------
class TestCheckTokenizerConfigNeedsV5:
"""Tests for local tokenizer_config.json fallback."""
def setup_method(self):
_tokenizer_class_cache.clear()
def test_local_tokenizer_config_v5(self, tmp_path: Path):
"""Local tokenizer_config.json with v5 tokenizer should return True."""
tc = {"tokenizer_class": "TokenizersBackend"}
(tmp_path / "tokenizer_config.json").write_text(json.dumps(tc))
result = _check_tokenizer_config_needs_v5(str(tmp_path))
assert result is True
def test_local_tokenizer_config_v4(self, tmp_path: Path):
"""Local tokenizer_config.json with standard tokenizer should return False."""
tc = {"tokenizer_class": "LlamaTokenizerFast"}
(tmp_path / "tokenizer_config.json").write_text(json.dumps(tc))
result = _check_tokenizer_config_needs_v5(str(tmp_path))
assert result is False
def test_local_file_skips_network(self, tmp_path: Path):
"""When local file exists, no network request should be made."""
tc = {"tokenizer_class": "LlamaTokenizerFast"}
(tmp_path / "tokenizer_config.json").write_text(json.dumps(tc))
with patch("urllib.request.urlopen") as mock_urlopen:
result = _check_tokenizer_config_needs_v5(str(tmp_path))
mock_urlopen.assert_not_called()
assert result is False
def test_result_is_cached(self, tmp_path: Path):
"""Subsequent calls should use the cache."""
tc = {"tokenizer_class": "TokenizersBackend"}
(tmp_path / "tokenizer_config.json").write_text(json.dumps(tc))
key = str(tmp_path)
_check_tokenizer_config_needs_v5(key)
assert key in _tokenizer_class_cache
assert _tokenizer_class_cache[key] is True
# ---------------------------------------------------------------------------
# needs_transformers_5 — integration-level
# ---------------------------------------------------------------------------
class TestNeedsTransformers5:
"""Integration tests for the top-level needs_transformers_5() function."""
def setup_method(self):
_tokenizer_class_cache.clear()
def test_qwen35_substring(self):
assert needs_transformers_5("Qwen/Qwen3.5-9B") is True
def test_qwen3_30b_a3b_substring(self):
assert needs_transformers_5("Qwen/Qwen3-30B-A3B-Instruct-2507") is True
def test_ministral_substring(self):
assert needs_transformers_5("mistralai/Ministral-3-8B-Instruct-2512") is True
def test_llama_does_not_need_v5(self):
"""Standard models should not trigger v5."""
# Patch network call to avoid a real fetch.
with patch(
"utils.transformers_version._check_tokenizer_config_needs_v5",
return_value = False,
):
assert needs_transformers_5("meta-llama/Llama-3-8B") is False
def test_local_checkpoint_resolved_via_config(self, tmp_path: Path):
"""Local checkpoint with config.json pointing to Qwen3.5 needs v5."""
config_cfg = {"model_name": "Qwen/Qwen3.5-9B"}
(tmp_path / "config.json").write_text(json.dumps(config_cfg))
# needs_transformers_5 only does substring matching, so test the
# full resolution chain via _resolve_base_model here.
resolved = _resolve_base_model(str(tmp_path))
assert needs_transformers_5(resolved) is True
# ---------------------------------------------------------------------------
# _check_config_needs_550 — config.json architecture/model_type check
# ---------------------------------------------------------------------------
class TestCheckConfigNeeds550:
"""Tests for _check_config_needs_550() local config.json checks."""
def setup_method(self):
_config_json_cache.clear()
_config_needs_550_cache.clear()
def test_gemma4_architecture(self, tmp_path: Path):
"""config.json with Gemma4ForConditionalGeneration should return True."""
cfg = {
"architectures": ["Gemma4ForConditionalGeneration"],
"model_type": "gemma4",
}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_550(str(tmp_path)) is True
def test_gemma4_model_type_only(self, tmp_path: Path):
"""config.json with model_type=gemma4 (no architectures) should return True."""
cfg = {"model_type": "gemma4"}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_550(str(tmp_path)) is True
def test_llama_architecture(self, tmp_path: Path):
"""config.json with LlamaForCausalLM should return False."""
cfg = {"architectures": ["LlamaForCausalLM"], "model_type": "llama"}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_550(str(tmp_path)) is False
def test_no_config_json(self, tmp_path: Path):
"""Missing config.json should return False (fail-open)."""
# Patch network call to avoid a real fetch.
with patch("urllib.request.urlopen") as mock_urlopen:
mock_urlopen.side_effect = Exception("no network")
assert _check_config_needs_550(str(tmp_path)) is False
def test_result_is_cached(self, tmp_path: Path):
"""Subsequent calls should use the cache."""
cfg = {"architectures": ["Gemma4ForConditionalGeneration"]}
(tmp_path / "config.json").write_text(json.dumps(cfg))
key = str(tmp_path)
_check_config_needs_550(key)
assert key in _config_needs_550_cache
assert _config_needs_550_cache[key] is True
def test_local_file_skips_network(self, tmp_path: Path):
"""When local config.json exists, no network request should be made."""
cfg = {"architectures": ["LlamaForCausalLM"]}
(tmp_path / "config.json").write_text(json.dumps(cfg))
with patch("urllib.request.urlopen") as mock_urlopen:
_check_config_needs_550(str(tmp_path))
mock_urlopen.assert_not_called()
# ---------------------------------------------------------------------------
# _check_config_needs_510 — config.json architecture/model_type check
# ---------------------------------------------------------------------------
class TestCheckConfigNeeds510:
"""Tests for _check_config_needs_510() local config.json checks."""
def setup_method(self):
_config_json_cache.clear()
_config_needs_510_cache.clear()
def test_gemma4_unified_architecture(self, tmp_path: Path):
"""config.json with Gemma4UnifiedForConditionalGeneration should return True."""
cfg = {
"architectures": ["Gemma4UnifiedForConditionalGeneration"],
"model_type": "gemma4_unified",
}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_510(str(tmp_path)) is True
def test_gemma4_unified_model_type_only(self, tmp_path: Path):
"""config.json with model_type=gemma4_unified should return True."""
cfg = {"model_type": "gemma4_unified"}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_510(str(tmp_path)) is True
def test_gemma4_unified_assistant_architecture(self, tmp_path: Path):
"""Assistant Gemma 4 Unified configs should return True."""
cfg = {
"architectures": ["Gemma4UnifiedAssistantForCausalLM"],
"model_type": "gemma4_unified_assistant",
}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_510(str(tmp_path)) is True
def test_gemma4_unified_assistant_model_type_only(self, tmp_path: Path):
"""Assistant Gemma 4 Unified model_type should return True."""
cfg = {"model_type": "gemma4_unified_assistant"}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_510(str(tmp_path)) is True
def test_gemma4_assistant_architecture(self, tmp_path: Path):
"""Assistant Gemma 4 configs should return True."""
cfg = {
"architectures": ["Gemma4AssistantForCausalLM"],
"model_type": "gemma4_assistant",
}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_510(str(tmp_path)) is True
def test_gemma4_assistant_model_type_only(self, tmp_path: Path):
"""Assistant Gemma 4 model_type should return True."""
cfg = {"model_type": "gemma4_assistant"}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_510(str(tmp_path)) is True
def test_gemma4_non_unified_returns_false(self, tmp_path: Path):
"""Older Gemma 4 config should stay on the 550 tier."""
cfg = {
"architectures": ["Gemma4ForConditionalGeneration"],
"model_type": "gemma4",
}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert _check_config_needs_510(str(tmp_path)) is False
def test_no_config_json(self, tmp_path: Path):
"""Missing config.json should return False (fail-open)."""
# Patch network call to avoid real fetch
with patch("urllib.request.urlopen") as mock_urlopen:
mock_urlopen.side_effect = Exception("no network")
assert _check_config_needs_510(str(tmp_path)) is False
def test_result_is_cached(self, tmp_path: Path):
"""Subsequent calls should use the cache."""
cfg = {"architectures": ["Gemma4UnifiedForConditionalGeneration"]}
(tmp_path / "config.json").write_text(json.dumps(cfg))
key = str(tmp_path)
_check_config_needs_510(key)
assert key in _config_needs_510_cache
assert _config_needs_510_cache[key] is True
def test_local_file_skips_network(self, tmp_path: Path):
"""When local config.json exists, no network request should be made."""
cfg = {"architectures": ["LlamaForCausalLM"]}
(tmp_path / "config.json").write_text(json.dumps(cfg))
with patch("urllib.request.urlopen") as mock_urlopen:
_check_config_needs_510(str(tmp_path))
mock_urlopen.assert_not_called()
# ---------------------------------------------------------------------------
# NemotronH dense (MLP) models need the 5.10 tier
# ---------------------------------------------------------------------------
class TestNemotronHNeedsMlpSupport:
"""Dense NemotronH configs (MLP layers) require transformers >= 5.10."""
def test_hybrid_override_pattern_with_dash(self):
cfg = {
"model_type": "nemotron_h",
"hybrid_override_pattern": "M-M-M*-M-",
}
assert _nemotron_h_needs_mlp_support(cfg) is True
def test_layers_block_type_with_mlp(self):
cfg = {
"model_type": "nemotron_h",
"layers_block_type": ["mamba", "mlp", "attention", "mamba"],
}
assert _nemotron_h_needs_mlp_support(cfg) is True
def test_nemotron_h_moe_only_returns_false(self):
"""A pure MoE NemotronH (no MLP) does not need the 5.10 tier."""
cfg = {
"model_type": "nemotron_h",
"hybrid_override_pattern": "MEME*MEM",
}
assert _nemotron_h_needs_mlp_support(cfg) is False
def test_non_nemotron_with_dash_returns_false(self):
"""The dash heuristic only applies to nemotron_h configs."""
cfg = {"model_type": "llama", "hybrid_override_pattern": "M-M-"}
assert _nemotron_h_needs_mlp_support(cfg) is False
def test_config_needs_510_includes_dense_nemotron_h(self):
cfg = {
"model_type": "nemotron_h",
"hybrid_override_pattern": "M-M-M*-",
}
assert _config_needs_510(cfg) is True
def test_nested_llm_config_with_dash(self):
# VL wrapper (e.g. NemotronH_Nano_VL_V2): dense LM is under llm_config.
cfg = {
"model_type": "NemotronH_Nano_VL_V2",
"llm_config": {"model_type": "nemotron_h", "hybrid_override_pattern": "M-M*-"},
}
assert _nemotron_h_needs_mlp_support(cfg) is True
assert _config_needs_510(cfg) is True
def test_nested_text_config_with_mlp(self):
cfg = {
"model_type": "wrapper",
"text_config": {"model_type": "nemotron_h", "layers_block_type": ["mamba", "mlp"]},
}
assert _nemotron_h_needs_mlp_support(cfg) is True
def test_nested_non_nemotron_returns_false(self):
cfg = {"model_type": "wrapper", "llm_config": {"model_type": "llama"}}
assert _nemotron_h_needs_mlp_support(cfg) is False
def test_non_dict_and_missing_nested_do_not_raise(self):
assert _nemotron_h_needs_mlp_support(None) is False
assert _nemotron_h_needs_mlp_support({"model_type": "wrapper", "llm_config": None}) is False
def _hf_response(cfg: dict):
"""A urlopen() context-manager stand-in returning *cfg* as JSON bytes."""
class _Resp:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return json.dumps(cfg).encode()
return _Resp()
class TestConfigJsonHfCacheFallback:
"""HF hub cache is consulted only offline or after a failed fetch (never stale online)."""
def setup_method(self):
_config_json_cache.clear()
@staticmethod
def _seed_cache(
hub: Path,
repo_id: str,
cfg: dict,
commit: str = "deadbeef",
):
repo = hub / ("models--" + repo_id.replace("/", "--"))
snap = repo / "snapshots" / commit
snap.mkdir(parents = True)
(snap / "config.json").write_text(json.dumps(cfg))
(repo / "refs").mkdir(parents = True)
(repo / "refs" / "main").write_text(commit)
def test_offline_reads_from_cache(self, tmp_path: Path, monkeypatch):
cfg = {"model_type": "nemotron_h", "hybrid_override_pattern": "M-M*-"}
self._seed_cache(tmp_path, "unsloth/NVIDIA-Nemotron-3-Nano-4B", cfg)
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
with patch("urllib.request.urlopen") as mock_url:
assert _load_config_json("unsloth/NVIDIA-Nemotron-3-Nano-4B") == cfg
mock_url.assert_not_called()
def test_online_prefers_network_over_cache(self, tmp_path: Path, monkeypatch):
stale = {"model_type": "nemotron_h", "hybrid_override_pattern": "MMMM"}
fresh = {"model_type": "nemotron_h", "hybrid_override_pattern": "M-M*-"}
self._seed_cache(tmp_path, "org/model", stale)
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising = False)
with patch("urllib.request.urlopen", return_value = _hf_response(fresh)):
assert _load_config_json("org/model") == fresh # network wins, not stale cache
def test_network_failure_falls_back_to_cache(self, tmp_path: Path, monkeypatch):
cfg = {"model_type": "nemotron_h", "hybrid_override_pattern": "M-M*-"}
self._seed_cache(tmp_path, "org/model", cfg)
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
with patch("urllib.request.urlopen", side_effect = OSError("boom")):
assert _load_config_json("org/model") == cfg
def test_offline_uncached_returns_none(self, tmp_path: Path, monkeypatch):
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
with patch("urllib.request.urlopen") as mock_url:
assert _load_config_json("private/unknown") is None
mock_url.assert_not_called()
def test_helper_ignores_local_paths(self, tmp_path: Path):
# A filesystem path is not a repo id; never treat it as one.
assert _config_json_from_hf_cache(str(tmp_path)) is None
assert _config_json_from_hf_cache("plainname") is None
def test_no_refs_main_picks_newest_snapshot(self, tmp_path: Path, monkeypatch):
# No refs/main (commit-pinned downloads): lexicographic order would pick the older
# SHA; selection must follow mtime so the newest snapshot wins.
repo = tmp_path / "models--org--model"
old = repo / "snapshots" / "0000old"
new = repo / "snapshots" / "ffffnew"
old.mkdir(parents = True)
new.mkdir(parents = True)
(old / "config.json").write_text(json.dumps({"model_type": "stale"}))
(new / "config.json").write_text(json.dumps({"model_type": "fresh"}))
os.utime(old / "config.json", (1000, 1000))
os.utime(new / "config.json", (2000, 2000))
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
assert _config_json_from_hf_cache("org/model") == {"model_type": "fresh"}
def test_transient_failure_does_not_cache_fallback(self, tmp_path: Path, monkeypatch):
stale = {"model_type": "nemotron_h", "hybrid_override_pattern": "MMMM"}
fresh = {"model_type": "nemotron_h", "hybrid_override_pattern": "M-M*-"}
self._seed_cache(tmp_path, "org/model", stale)
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
# Network fails -> serve the cached snapshot, but it must not be memoized.
with patch("urllib.request.urlopen", side_effect = OSError("boom")):
assert _load_config_json("org/model") == stale
# Connectivity returns: the next call must hit the network for the fresh config.
with patch("urllib.request.urlopen", return_value = _hf_response(fresh)):
assert _load_config_json("org/model") == fresh
def test_auth_failure_does_not_serve_cache(self, tmp_path: Path, monkeypatch):
import urllib.error
# config.json cached from an earlier authorized session; an unauthenticated 4xx
# must not be handed that private metadata.
cfg = {"model_type": "nemotron_h", "hybrid_override_pattern": "M-M*-"}
self._seed_cache(tmp_path, "private/model", cfg)
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
for code in (401, 403, 404):
_config_json_cache.clear()
err = urllib.error.HTTPError("url", code, "denied", {}, None)
with patch("urllib.request.urlopen", side_effect = err):
assert _load_config_json("private/model") is None
def test_server_error_still_falls_back_to_cache(self, tmp_path: Path, monkeypatch):
import urllib.error
cfg = {"model_type": "nemotron_h", "hybrid_override_pattern": "M-M*-"}
self._seed_cache(tmp_path, "org/model", cfg)
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
# A 5xx is transient, not an access decision: keep serving the cache.
err = urllib.error.HTTPError("url", 503, "busy", {}, None)
with patch("urllib.request.urlopen", side_effect = err):
assert _load_config_json("org/model") == cfg
class TestTierCheckTransientRetry:
"""tier-needs checks must not memoize a transient fetch fallback."""
def setup_method(self):
_config_json_cache.clear()
_config_needs_510_cache.clear()
_config_needs_550_cache.clear()
@staticmethod
def _seed_cache(
hub: Path,
repo_id: str,
cfg: dict,
commit: str = "deadbeef",
):
repo = hub / ("models--" + repo_id.replace("/", "--"))
snap = repo / "snapshots" / commit
snap.mkdir(parents = True)
(snap / "config.json").write_text(json.dumps(cfg))
(repo / "refs").mkdir(parents = True)
(repo / "refs" / "main").write_text(commit)
def test_transient_fallback_not_memoized_then_retries(self, tmp_path: Path, monkeypatch):
stale = {"model_type": "llama"} # does not need 510
fresh = {"architectures": ["Gemma4UnifiedForConditionalGeneration"]} # needs 510
self._seed_cache(tmp_path, "org/model", stale)
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
# Network blip -> serve the cache, but do NOT pin the tier result.
with patch("urllib.request.urlopen", side_effect = OSError("boom")):
assert _check_config_needs_510("org/model") is False
assert "org/model" not in _config_needs_510_cache
# Connectivity returns: the next call re-fetches and sees the higher tier.
with patch("urllib.request.urlopen", return_value = _hf_response(fresh)):
assert _check_config_needs_510("org/model") is True
assert _config_needs_510_cache["org/model"] is True # definitive read is memoized
def test_definitive_network_read_is_memoized(self, tmp_path: Path, monkeypatch):
fresh = {"architectures": ["Gemma4ForConditionalGeneration"]} # needs 550
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
with patch("urllib.request.urlopen", return_value = _hf_response(fresh)) as mock_url:
assert _check_config_needs_550("org/model") is True
assert _check_config_needs_550("org/model") is True
assert mock_url.call_count == 1 # second call served from the tier cache
class TestHigherTier:
def test_picks_stronger_tier(self):
assert _higher_tier("default", "510") == "510"
assert _higher_tier("530", "550") == "550"
assert _higher_tier("510", "default") == "510"
assert _higher_tier("default", "default") == "default"
# ---------------------------------------------------------------------------
# get_transformers_tier — tier detection
# ---------------------------------------------------------------------------
class TestGetTransformersTier:
"""Tests for get_transformers_tier() tiered version detection."""
def setup_method(self):
_tokenizer_class_cache.clear()
_config_json_cache.clear()
_config_needs_510_cache.clear()
_config_needs_550_cache.clear()
def test_gemma4_substring_returns_550(self):
assert get_transformers_tier("google/gemma-4-E2B-it") == "550"
def test_gemma4_12b_substring_returns_510(self):
assert get_transformers_tier("unsloth/gemma-4-12b-it") == "510"
def test_gemma4_assistant_substring_returns_510(self):
assert get_transformers_tier("google/gemma-4-E2B-it-assistant") == "510"
def test_gemma4_alt_substring_returns_550(self):
assert get_transformers_tier("unsloth/gemma4-E4B-it") == "550"
def test_gemma4_config_json_returns_550(self, tmp_path: Path):
"""Local checkpoint with Gemma4 architecture → 550."""
cfg = {
"architectures": ["Gemma4ForConditionalGeneration"],
"model_type": "gemma4",
}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert get_transformers_tier(str(tmp_path)) == "550"
def test_gemma4_unified_config_json_returns_510(self, tmp_path: Path):
"""Local checkpoint with Gemma4 Unified architecture → 510."""
cfg = {
"architectures": ["Gemma4UnifiedForConditionalGeneration"],
"model_type": "gemma4_unified",
}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert get_transformers_tier(str(tmp_path)) == "510"
def test_gemma4_assistant_config_json_returns_510(self, tmp_path: Path):
"""Local checkpoint with Gemma4 Assistant architecture → 510."""
cfg = {
"architectures": ["Gemma4AssistantForCausalLM"],
"model_type": "gemma4_assistant",
}
(tmp_path / "config.json").write_text(json.dumps(cfg))
assert get_transformers_tier(str(tmp_path)) == "510"
def test_dense_nemotron_h_config_json_returns_510(self, tmp_path: Path):
"""Local dense NemotronH checkpoint → 510 (MLP layers need >= 5.10)."""
cfg = {
"model_type": "nemotron_h",
"hybrid_override_pattern": "M-M-M*-M-",
}
(tmp_path / "config.json").write_text(json.dumps(cfg))
# A v5 tokenizer would otherwise route this to 530; 510 must win.
(tmp_path / "tokenizer_config.json").write_text(
json.dumps({"tokenizer_class": "TokenizersBackend"})
)
with patch("urllib.request.urlopen") as mock_urlopen:
assert get_transformers_tier(str(tmp_path)) == "510"
mock_urlopen.assert_not_called()
def test_dense_nemotron_h_remote_config_returns_510(self):
"""Remote dense NemotronH (HF id) → 510 via config.json fetch, not 530."""
class _Response:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return json.dumps(
{
"model_type": "nemotron_h",
"hybrid_override_pattern": "M-M-M*-M-",
}
).encode()
with patch("urllib.request.urlopen", return_value = _Response()):
assert get_transformers_tier("unsloth/NVIDIA-Nemotron-3-Nano-4B") == "510"
def test_local_config_json_short_circuits_path_substrings(self, tmp_path: Path):
"""Local config.json should prevent false matches from parent directory names."""
model_dir = tmp_path / "gemma-4-12b-experiment" / "llama-checkpoint"
model_dir.mkdir(parents = True)
(model_dir / "config.json").write_text(
json.dumps(
{
"architectures": ["LlamaForCausalLM"],
"model_type": "llama",
}
)
)
(model_dir / "tokenizer_config.json").write_text(
json.dumps({"tokenizer_class": "LlamaTokenizerFast"})
)
with patch("urllib.request.urlopen") as mock_urlopen:
assert get_transformers_tier(str(model_dir)) == "default"
mock_urlopen.assert_not_called()
def test_remote_config_json_is_fetched_once_for_config_tiers(self):
"""510 and 550 slow-path checks should share one config.json fetch."""
class _Response:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return json.dumps(
{
"architectures": ["Gemma4ForConditionalGeneration"],
"model_type": "gemma4",
}
).encode()
with patch("urllib.request.urlopen", return_value = _Response()) as mock_urlopen:
assert get_transformers_tier("org/no-fast-substring-model") == "550"
assert mock_urlopen.call_count == 1
def test_qwen35_returns_530(self):
with (
patch(
"utils.transformers_version._check_config_needs_550",
return_value = False,
),
patch(
"utils.transformers_version._check_config_needs_510",
return_value = False,
),
):
assert get_transformers_tier("Qwen/Qwen3.5-9B") == "530"
def test_ministral_returns_530(self):
with (
patch(
"utils.transformers_version._check_config_needs_550",
return_value = False,
),
patch(
"utils.transformers_version._check_config_needs_510",
return_value = False,
),
):
assert get_transformers_tier("mistralai/Ministral-3-8B-Instruct-2512") == "530"
def test_llama_returns_default(self):
with (
patch(
"utils.transformers_version._check_config_needs_550",
return_value = False,
),
patch(
"utils.transformers_version._check_config_needs_510",
return_value = False,
),
patch(
"utils.transformers_version._check_tokenizer_config_needs_v5",
return_value = False,
),
):
assert get_transformers_tier("meta-llama/Llama-3-8B") == "default"
def test_550_checked_before_530(self):
"""5.5.0 is checked before 5.3.0 - a model matching both gets 550."""
assert get_transformers_tier("gemma-4-model") == "550"
# ---- issue #6103: the tier decision must be traceable in the logs ----
def test_tier_550_selection_is_logged(self, caplog):
caplog.set_level(logging.INFO)
assert get_transformers_tier("google/gemma-4-E2B-it") == "550"
text = " ".join(r.getMessage() for r in caplog.records).lower()
assert "550" in text, f"tier selection not logged: {text!r}"
assert "gemma-4-e2b-it" in text, f"tier log omits the model: {text!r}"
def test_tier_530_selection_is_logged(self, caplog):
caplog.set_level(logging.INFO)
with patch(
"utils.transformers_version._check_config_needs_550",
return_value = False,
):
assert get_transformers_tier("Qwen/Qwen3.5-9B") == "530"
text = " ".join(r.getMessage() for r in caplog.records).lower()
assert "530" in text, f"tier selection not logged: {text!r}"
assert "qwen3.5-9b" in text, f"tier log omits the model: {text!r}"
def test_tier_default_selection_is_logged(self, caplog):
caplog.set_level(logging.INFO)
with (
patch(
"utils.transformers_version._check_config_needs_510",
return_value = False,
),
patch(
"utils.transformers_version._check_config_needs_550",
return_value = False,
),
patch(
"utils.transformers_version._check_tokenizer_config_needs_v5",
return_value = False,
),
):
assert get_transformers_tier("meta-llama/Llama-3-8B") == "default"
text = " ".join(r.getMessage() for r in caplog.records).lower()
assert "default" in text, f"tier selection not logged: {text!r}"
def test_local_config_json_selection_is_logged(self, tmp_path: Path, caplog):
cfg = {"architectures": ["Gemma4ForConditionalGeneration"], "model_type": "gemma4"}
(tmp_path / "config.json").write_text(json.dumps(cfg))
caplog.set_level(logging.INFO)
assert get_transformers_tier(str(tmp_path)) == "550"
text = " ".join(r.getMessage() for r in caplog.records).lower()
assert "550" in text and "local config.json" in text, f"local tier not logged: {text!r}"
def test_needs_transformers_5_compat(self):
"""needs_transformers_5 should return True for 510, 530, and 550 models."""
assert needs_transformers_5("unsloth/gemma-4-12b-it") is True
assert needs_transformers_5("google/gemma-4-E2B-it") is True
with (
patch(
"utils.transformers_version._check_config_needs_550",
return_value = False,
),
patch(
"utils.transformers_version._check_config_needs_510",
return_value = False,
),
):
assert needs_transformers_5("Qwen/Qwen3.5-9B") is True
with (
patch(
"utils.transformers_version._check_config_needs_550",
return_value = False,
),
patch(
"utils.transformers_version._check_config_needs_510",
return_value = False,
),
patch(
"utils.transformers_version._check_tokenizer_config_needs_v5",
return_value = False,
),
):
assert needs_transformers_5("meta-llama/Llama-3-8B") is False
# ---------------------------------------------------------------------------
# activate_transformers_for_subprocess — issue #6103
# The early log must make clear it only prepends to sys.path; the real
# confirmation comes later from "Subprocess loaded transformers X.X.X".
# ---------------------------------------------------------------------------
class TestActivateLoggingClarity:
"""issue #6103: 'Activated transformers' was misleading (path-prepend only)."""
def _snapshot_env(self):
return list(sys.path), os.environ.get("PYTHONPATH")
def _restore_env(self, snapshot):
saved_path, saved_pp = snapshot
sys.path[:] = saved_path
if saved_pp is None:
os.environ.pop("PYTHONPATH", None)
else:
os.environ["PYTHONPATH"] = saved_pp
def test_activate_550_log_clarifies_path_prepend_only(self, caplog):
caplog.set_level(logging.INFO)
snap = self._snapshot_env()
try:
with (
patch(
"utils.transformers_version._resolve_base_model",
side_effect = lambda m: m,
),
patch(
"utils.transformers_version.get_transformers_tier",
return_value = "550",
),
patch(
"utils.transformers_version._ensure_venv_t5_550_exists",
return_value = True,
),
):
activate_transformers_for_subprocess("google/gemma-4-E2B-it")
finally:
self._restore_env(snap)
text = " ".join(r.getMessage() for r in caplog.records).lower()
assert "5.5.0" in text, f"version not logged: {text!r}"
# Must signal this is only a sys.path manipulation, not a confirmed import.
assert (
"sys.path" in text or "path only" in text
), f"early activation log does not clarify it is path-prepend only: {text!r}"
def test_activate_530_log_clarifies_path_prepend_only(self, caplog):
caplog.set_level(logging.INFO)
snap = self._snapshot_env()
try:
with (
patch(
"utils.transformers_version._resolve_base_model",
side_effect = lambda m: m,
),
patch(
"utils.transformers_version.get_transformers_tier",
return_value = "530",
),
patch(
"utils.transformers_version._ensure_venv_t5_530_exists",
return_value = True,
),
):
activate_transformers_for_subprocess("Qwen/Qwen3.5-9B")
finally:
self._restore_env(snap)
text = " ".join(r.getMessage() for r in caplog.records).lower()
assert "5.3.0" in text, f"version not logged: {text!r}"
assert (
"sys.path" in text or "path only" in text
), f"early activation log does not clarify it is path-prepend only: {text!r}"
def test_activate_prefers_local_checkpoint_tier_over_resolved_base(self, caplog, tmp_path):
# Base resolves to an offline/private id (default tier); the local config.json wins.
(tmp_path / "config.json").write_text(json.dumps({"model_type": "llama"}))
local = str(tmp_path)
caplog.set_level(logging.INFO)
snap = self._snapshot_env()
tiers = {local: "510", "private/base": "default"}
try:
with (
patch(
"utils.transformers_version._resolve_base_model",
return_value = "private/base",
),
patch(
"utils.transformers_version.get_transformers_tier",
side_effect = lambda m: tiers[m],
),
patch(
"utils.transformers_version._ensure_venv_t5_510_exists",
return_value = True,
),
):
activate_transformers_for_subprocess(local)
finally:
self._restore_env(snap)
text = " ".join(r.getMessage() for r in caplog.records).lower()
assert "5.10.2" in text, f"local checkpoint tier did not win: {text!r}"
def test_activate_adapter_without_config_skips_path_name_recheck(self, caplog, tmp_path):
# Adapter dir named 'gemma-4' but no config.json: the path-name re-check must not run.
adapter = tmp_path / "gemma-4-experiment" / "llama-lora"
adapter.mkdir(parents = True)
local = str(adapter)
caplog.set_level(logging.INFO)
snap = self._snapshot_env()
seen = []
def fake_tier(m):
seen.append(m)
return "550" if "gemma-4" in m else "default"
try:
with (
patch(
"utils.transformers_version._resolve_base_model",
return_value = "meta/llama",
),
patch(
"utils.transformers_version.get_transformers_tier",
side_effect = fake_tier,
),
):
activate_transformers_for_subprocess(local)
finally:
self._restore_env(snap)
assert seen == ["meta/llama"], f"adapter path was re-checked via substrings: {seen!r}"
text = " ".join(r.getMessage() for r in caplog.records).lower()
assert "default transformers" in text, f"adapter wrongly upgraded: {text!r}"
# ---------------------------------------------------------------------------
# _venv_dir_is_valid — issue #6103
# A version mismatch triggers a full wipe + reinstall, so it must be logged
# at WARNING (not INFO) so the reinstall is visible.
# ---------------------------------------------------------------------------
class TestVenvDirIsValidLogging:
def _make_venv(self, venv_dir: Path, pkg: str, version: str):
"""Create a fake target-dir install of *pkg* at *version*."""
(venv_dir / pkg).mkdir(parents = True)
di = venv_dir / f"{pkg}-{version}.dist-info"
di.mkdir()
(di / "METADATA").write_text(f"Name: {pkg}\nVersion: {version}\n")
def test_version_mismatch_logged_at_warning(self, tmp_path: Path, caplog):
venv_dir = tmp_path / "venv"
self._make_venv(venv_dir, "transformers", "5.0.0") # wrong version
caplog.set_level(logging.INFO)
result = _venv_dir_is_valid(str(venv_dir), ("transformers==5.3.0",))
assert result is False
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert warnings, (
"version mismatch must be logged at WARNING; got: "
f"{[(r.levelname, r.getMessage()) for r in caplog.records]!r}"
)
joined = " ".join(r.getMessage() for r in warnings)
assert (
"5.0.0" in joined and "5.3.0" in joined
), f"mismatch log omits the versions: {joined!r}"
def test_correct_version_does_not_warn(self, tmp_path: Path, caplog):
venv_dir = tmp_path / "venv"
self._make_venv(venv_dir, "transformers", "5.3.0") # correct version
caplog.set_level(logging.INFO)
result = _venv_dir_is_valid(str(venv_dir), ("transformers==5.3.0",))
assert result is True
assert not [
r for r in caplog.records if r.levelno >= logging.WARNING
], "no warning expected when the installed version matches"
# ---------------------------------------------------------------------------
# _ensure_venv_dir — issue #6103
# A slow runtime install must log each package as it starts, otherwise it
# looks like a hang.
# ---------------------------------------------------------------------------
class TestEnsureVenvDirProgressLogging:
def test_logs_each_package_with_progress(self, tmp_path: Path, caplog):
installed = []
caplog.set_level(logging.INFO)
with (
patch(
"utils.transformers_version._venv_dir_is_valid",
return_value = False,
),
patch(
"utils.transformers_version._install_to_dir",
side_effect = lambda pkg, d: (installed.append(pkg), True)[1],
),
):
ok = _ensure_venv_dir(
str(tmp_path / "venv"),
("transformers==5.3.0", "tokenizers==0.21.0"),
"transformers 5.3.0",
)
assert ok is True
assert installed == ["transformers==5.3.0", "tokenizers==0.21.0"]
msgs = " ".join(r.getMessage() for r in caplog.records)
assert "transformers==5.3.0" in msgs, f"first package not logged: {msgs!r}"
assert "tokenizers==0.21.0" in msgs, f"second package not logged: {msgs!r}"
# progress counter present so a slow install is not mistaken for a hang
assert "1/2" in msgs and "2/2" in msgs, f"progress count missing: {msgs!r}"
def test_no_install_logging_when_venv_already_valid(self, tmp_path: Path, caplog):
caplog.set_level(logging.INFO)
with (
patch(
"utils.transformers_version._venv_dir_is_valid",
return_value = True,
),
patch(
"utils.transformers_version._install_to_dir",
) as mock_install,
):
ok = _ensure_venv_dir(
str(tmp_path / "venv"),
("transformers==5.3.0",),
"transformers 5.3.0",
)
assert ok is True
mock_install.assert_not_called()
assert "Installing" not in " ".join(r.getMessage() for r in caplog.records)