Pin Studio GGUF export to llama.cpp's local convert script (#5275)
* Pin Studio GGUF export to local llama.cpp convert script setdefault UNSLOTH_LLAMA_CPP_SCRIPTS_DIR=LLAMA_CPP_DEFAULT_DIR before save_pretrained_gguf so the convert_hf_to_gguf.py used at conversion time matches the pinned llama-quantize binary and gguf-py installed under ~/.unsloth/llama.cpp. Without this, the script is pulled from upstream master and can drift past the binary's gguf API, causing intermittent export failures. setdefault preserves any explicit user override; validation of the path lives in unsloth_zoo's _resolve_local_convert_script (warns and falls back to network on a bad value). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Scrub .github/workflows for staging push (matches staging base) * Pin GGUF convert script for hub-only export path Hoist the UNSLOTH_LLAMA_CPP_SCRIPTS_DIR setdefault and the unsloth_zoo.llama_cpp import out of the if save_directory: block so push_to_hub_gguf also runs with the pin. The worker passes save_directory="" for hub-only exports, which previously skipped the local branch and left the convert script fetched from master. * Trim GGUF convert script pin rationale comment Collapse 7 lines of rationale into 3 lines stating the load-bearing facts: pin matches llama-quantize binary, set before both branches because hub-only export has empty save_directory. * Sync .github/workflows with upstream author branch * Scrub .github/workflows for staging push (matches staging base) * Warn when unsloth_zoo is too old to honor UNSLOTH_LLAMA_CPP_SCRIPTS_DIR Studio's GGUF export sets UNSLOTH_LLAMA_CPP_SCRIPTS_DIR before save_pretrained_gguf and push_to_hub_gguf so unsloth_zoo can prefer the local pinned convert_hf_to_gguf.py. The resolver only exists in the companion unsloth_zoo change; on older zoo builds permitted by the current dependency floor, the env var is silently ignored and the converter is still downloaded from llama.cpp master. Probe for the resolver and emit a one-time warning so operators know the pin is inactive and can upgrade unsloth_zoo. * Combine the GGUF script-pin imports into one guarded block and warn once Both LLAMA_CPP_DEFAULT_DIR and the resolver probe come from unsloth_zoo.llama_cpp; older zoo wheels (e.g. 2026.1.4) lack LLAMA_CPP_DEFAULT_DIR, so the previous unguarded import could crash the GGUF export path on environments installed with --no-deps or a manually pinned zoo. Move the constant import alongside the resolver probe inside a single try/except ImportError so a missing symbol degrades to the warning instead of a hard crash, matching the graceful-degradation intent the probe was added for. The compatibility warning previously fired on every export call because 'from X import Y' re-raises ImportError on every invocation when Y is absent. Gate emission on a module-level flag so operators see it once per process instead of once per export. * Add Studio GGUF export script-pin test coverage Consolidate tests for the UNSLOTH_LLAMA_CPP_SCRIPTS_DIR env-var pin in ExportBackend.export_gguf into a single behavior-named module: - AST-asserts the module-level _LLAMA_CPP_SCRIPTS_WARNING_EMITTED flag, the merged try-block importing both LLAMA_CPP_DEFAULT_DIR and _resolve_local_convert_script, and the warn-once gate inside the ImportError handler. - Behaviorally verifies setdefault preserves explicit user overrides, assigns the default when unset, fires the compatibility warning at most once across multiple export calls, and degrades to a warning (without setting the env var) when LLAMA_CPP_DEFAULT_DIR itself is missing on an older unsloth_zoo. * Sync .github/workflows with upstream author branch * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
This commit is contained in:
parent
2ef98d382e
commit
b39f4b282a
2 changed files with 258 additions and 0 deletions
|
|
@ -28,6 +28,8 @@ from core.inference import get_inference_backend
|
|||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_LLAMA_CPP_SCRIPTS_WARNING_EMITTED = False
|
||||
|
||||
|
||||
def _is_wsl():
|
||||
"""Detect if running under Windows Subsystem for Linux."""
|
||||
|
|
@ -529,6 +531,31 @@ class ExportBackend:
|
|||
# Convert quantization method to lowercase for unsloth
|
||||
quant_method = quantization_method.lower()
|
||||
|
||||
# Pin convert_hf_to_gguf.py to the same llama.cpp ref as the
|
||||
# llama-quantize binary (Studio installs at a tagged ref via
|
||||
# setup.sh) so it can't drift past the pinned binary's gguf API.
|
||||
# Set before both branches; hub-only export has save_directory == "".
|
||||
global _LLAMA_CPP_SCRIPTS_WARNING_EMITTED
|
||||
try:
|
||||
from unsloth_zoo.llama_cpp import (
|
||||
LLAMA_CPP_DEFAULT_DIR,
|
||||
_resolve_local_convert_script, # noqa: F401
|
||||
)
|
||||
|
||||
os.environ.setdefault(
|
||||
"UNSLOTH_LLAMA_CPP_SCRIPTS_DIR", LLAMA_CPP_DEFAULT_DIR
|
||||
)
|
||||
except ImportError:
|
||||
if not _LLAMA_CPP_SCRIPTS_WARNING_EMITTED:
|
||||
logger.warning(
|
||||
"Unsloth: installed unsloth_zoo does not honor "
|
||||
"UNSLOTH_LLAMA_CPP_SCRIPTS_DIR; convert_hf_to_gguf.py will "
|
||||
"still be downloaded from llama.cpp master and may drift "
|
||||
"past the pinned llama-quantize binary. Upgrade unsloth_zoo "
|
||||
"to activate the local script pin."
|
||||
)
|
||||
_LLAMA_CPP_SCRIPTS_WARNING_EMITTED = True
|
||||
|
||||
# Save locally if requested
|
||||
if save_directory:
|
||||
save_directory = str(resolve_export_dir(save_directory))
|
||||
|
|
|
|||
231
tests/studio/test_studio_gguf_export_script_pin.py
Normal file
231
tests/studio/test_studio_gguf_export_script_pin.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"""Tests for Studio GGUF export pinning convert_hf_to_gguf.py via
|
||||
UNSLOTH_LLAMA_CPP_SCRIPTS_DIR with graceful fallback when unsloth_zoo
|
||||
lacks the local-script resolver.
|
||||
|
||||
Verifies:
|
||||
- export.py imports LLAMA_CPP_DEFAULT_DIR and _resolve_local_convert_script
|
||||
from unsloth_zoo.llama_cpp inside a single try/except ImportError so a
|
||||
zoo missing either symbol degrades to a warning instead of crashing.
|
||||
- os.environ.setdefault("UNSLOTH_LLAMA_CPP_SCRIPTS_DIR", LLAMA_CPP_DEFAULT_DIR)
|
||||
is called inside the try; setdefault preserves explicit user overrides
|
||||
and assigns the default when unset.
|
||||
- The compatibility warning is gated on a module-level flag so it fires
|
||||
once per process rather than on every export call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SOURCE_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "studio"
|
||||
/ "backend"
|
||||
/ "core"
|
||||
/ "export"
|
||||
/ "export.py"
|
||||
)
|
||||
SRC = SOURCE_PATH.read_text()
|
||||
TREE = ast.parse(SRC)
|
||||
|
||||
|
||||
def _module_level_assignments(tree: ast.Module):
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name):
|
||||
yield target.id, node.value
|
||||
|
||||
|
||||
def _find_pin_try(tree: ast.AST):
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Try):
|
||||
continue
|
||||
for stmt in node.body:
|
||||
if (
|
||||
isinstance(stmt, ast.ImportFrom)
|
||||
and stmt.module == "unsloth_zoo.llama_cpp"
|
||||
and any(
|
||||
alias.name == "_resolve_local_convert_script"
|
||||
for alias in stmt.names
|
||||
)
|
||||
):
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def test_warning_flag_defined_at_module_scope():
|
||||
flags = {
|
||||
name: value
|
||||
for name, value in _module_level_assignments(TREE)
|
||||
if name == "_LLAMA_CPP_SCRIPTS_WARNING_EMITTED"
|
||||
}
|
||||
assert flags, "expected module-level _LLAMA_CPP_SCRIPTS_WARNING_EMITTED flag"
|
||||
init = flags["_LLAMA_CPP_SCRIPTS_WARNING_EMITTED"]
|
||||
assert isinstance(init, ast.Constant) and init.value is False
|
||||
|
||||
|
||||
def test_constant_and_resolver_imported_in_same_try():
|
||||
try_node = _find_pin_try(TREE)
|
||||
assert try_node is not None
|
||||
imported = []
|
||||
for stmt in try_node.body:
|
||||
if isinstance(stmt, ast.ImportFrom) and stmt.module == "unsloth_zoo.llama_cpp":
|
||||
imported.extend(alias.name for alias in stmt.names)
|
||||
assert "LLAMA_CPP_DEFAULT_DIR" in imported
|
||||
assert "_resolve_local_convert_script" in imported
|
||||
|
||||
|
||||
def test_setdefault_inside_try_block():
|
||||
try_node = _find_pin_try(TREE)
|
||||
assert try_node is not None
|
||||
setdefault_calls = []
|
||||
for stmt in try_node.body:
|
||||
for node in ast.walk(stmt):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "setdefault"
|
||||
and isinstance(node.func.value, ast.Attribute)
|
||||
and node.func.value.attr == "environ"
|
||||
and node.args
|
||||
and isinstance(node.args[0], ast.Constant)
|
||||
and node.args[0].value == "UNSLOTH_LLAMA_CPP_SCRIPTS_DIR"
|
||||
):
|
||||
setdefault_calls.append(node)
|
||||
assert setdefault_calls
|
||||
second = setdefault_calls[0].args[1]
|
||||
assert isinstance(second, ast.Name) and second.id == "LLAMA_CPP_DEFAULT_DIR"
|
||||
|
||||
|
||||
def test_warning_handler_gated_on_module_flag():
|
||||
try_node = _find_pin_try(TREE)
|
||||
assert try_node is not None
|
||||
handlers = [
|
||||
h
|
||||
for h in try_node.handlers
|
||||
if isinstance(h.type, ast.Name) and h.type.id == "ImportError"
|
||||
]
|
||||
assert handlers
|
||||
handler = handlers[0]
|
||||
flag_reads = []
|
||||
flag_writes = []
|
||||
warning_calls = []
|
||||
for node in ast.walk(ast.Module(body = handler.body, type_ignores = [])):
|
||||
if (
|
||||
isinstance(node, ast.Name)
|
||||
and node.id == "_LLAMA_CPP_SCRIPTS_WARNING_EMITTED"
|
||||
):
|
||||
if isinstance(node.ctx, ast.Load):
|
||||
flag_reads.append(node)
|
||||
elif isinstance(node.ctx, ast.Store):
|
||||
flag_writes.append(node)
|
||||
elif (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "warning"
|
||||
):
|
||||
warning_calls.append(node)
|
||||
assert flag_reads
|
||||
assert flag_writes
|
||||
assert warning_calls
|
||||
msg = ast.dump(warning_calls[0])
|
||||
assert "UNSLOTH_LLAMA_CPP_SCRIPTS_DIR" in msg
|
||||
assert "unsloth_zoo" in msg
|
||||
|
||||
|
||||
def test_default_dir_is_string_for_setdefault_compat():
|
||||
from unsloth_zoo.llama_cpp import LLAMA_CPP_DEFAULT_DIR
|
||||
|
||||
assert isinstance(LLAMA_CPP_DEFAULT_DIR, str)
|
||||
|
||||
|
||||
def test_setdefault_preserves_explicit_user_override(monkeypatch):
|
||||
monkeypatch.setenv("UNSLOTH_LLAMA_CPP_SCRIPTS_DIR", "/explicit/override")
|
||||
from unsloth_zoo.llama_cpp import LLAMA_CPP_DEFAULT_DIR
|
||||
|
||||
os.environ.setdefault("UNSLOTH_LLAMA_CPP_SCRIPTS_DIR", LLAMA_CPP_DEFAULT_DIR)
|
||||
assert os.environ["UNSLOTH_LLAMA_CPP_SCRIPTS_DIR"] == "/explicit/override"
|
||||
|
||||
|
||||
def test_setdefault_assigns_default_when_unset(monkeypatch):
|
||||
monkeypatch.delenv("UNSLOTH_LLAMA_CPP_SCRIPTS_DIR", raising = False)
|
||||
from unsloth_zoo.llama_cpp import LLAMA_CPP_DEFAULT_DIR
|
||||
|
||||
os.environ.setdefault("UNSLOTH_LLAMA_CPP_SCRIPTS_DIR", LLAMA_CPP_DEFAULT_DIR)
|
||||
assert os.environ["UNSLOTH_LLAMA_CPP_SCRIPTS_DIR"] == LLAMA_CPP_DEFAULT_DIR
|
||||
|
||||
|
||||
def _simulate_pin_block(emit_records, set_value):
|
||||
fake = types.ModuleType("unsloth_zoo.llama_cpp")
|
||||
if set_value is not None:
|
||||
fake.LLAMA_CPP_DEFAULT_DIR = set_value
|
||||
sys.modules["unsloth_zoo.llama_cpp"] = fake
|
||||
|
||||
state = {"emitted": False}
|
||||
|
||||
def run_once():
|
||||
try:
|
||||
from unsloth_zoo.llama_cpp import (
|
||||
LLAMA_CPP_DEFAULT_DIR,
|
||||
_resolve_local_convert_script, # noqa: F401
|
||||
)
|
||||
|
||||
os.environ.setdefault(
|
||||
"UNSLOTH_LLAMA_CPP_SCRIPTS_DIR", LLAMA_CPP_DEFAULT_DIR
|
||||
)
|
||||
except ImportError:
|
||||
if not state["emitted"]:
|
||||
emit_records.append("warned")
|
||||
state["emitted"] = True
|
||||
|
||||
return run_once
|
||||
|
||||
|
||||
def test_warning_fires_at_most_once_across_calls(monkeypatch):
|
||||
monkeypatch.delenv("UNSLOTH_LLAMA_CPP_SCRIPTS_DIR", raising = False)
|
||||
emits = []
|
||||
runner = _simulate_pin_block(emits, set_value = "/fake/default")
|
||||
runner()
|
||||
runner()
|
||||
runner()
|
||||
assert emits == ["warned"]
|
||||
|
||||
|
||||
def test_missing_default_dir_degrades_to_warning(monkeypatch):
|
||||
monkeypatch.delenv("UNSLOTH_LLAMA_CPP_SCRIPTS_DIR", raising = False)
|
||||
emits = []
|
||||
runner = _simulate_pin_block(emits, set_value = None)
|
||||
runner()
|
||||
assert emits == ["warned"]
|
||||
assert "UNSLOTH_LLAMA_CPP_SCRIPTS_DIR" not in os.environ
|
||||
|
||||
|
||||
def test_no_warning_when_both_symbols_present(monkeypatch):
|
||||
monkeypatch.delenv("UNSLOTH_LLAMA_CPP_SCRIPTS_DIR", raising = False)
|
||||
fake = types.ModuleType("unsloth_zoo.llama_cpp")
|
||||
fake.LLAMA_CPP_DEFAULT_DIR = "/fake/dir"
|
||||
fake._resolve_local_convert_script = lambda: None
|
||||
monkeypatch.setitem(sys.modules, "unsloth_zoo.llama_cpp", fake)
|
||||
|
||||
emits = []
|
||||
state = {"emitted": False}
|
||||
try:
|
||||
from unsloth_zoo.llama_cpp import (
|
||||
LLAMA_CPP_DEFAULT_DIR,
|
||||
_resolve_local_convert_script, # noqa: F401
|
||||
)
|
||||
|
||||
os.environ.setdefault("UNSLOTH_LLAMA_CPP_SCRIPTS_DIR", LLAMA_CPP_DEFAULT_DIR)
|
||||
except ImportError:
|
||||
if not state["emitted"]:
|
||||
emits.append("warned")
|
||||
state["emitted"] = True
|
||||
|
||||
assert emits == []
|
||||
assert os.environ.get("UNSLOTH_LLAMA_CPP_SCRIPTS_DIR") == "/fake/dir"
|
||||
Loading…
Add table
Add a link
Reference in a new issue