From b39f4b282ac58d8818ee366a7dfde498bd6c4fcc Mon Sep 17 00:00:00 2001 From: DoubleMathew Date: Tue, 5 May 2026 06:03:28 -0500 Subject: [PATCH] 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 Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> --- studio/backend/core/export/export.py | 27 ++ .../test_studio_gguf_export_script_pin.py | 231 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 tests/studio/test_studio_gguf_export_script_pin.py diff --git a/studio/backend/core/export/export.py b/studio/backend/core/export/export.py index d8f2e8fa37..6fee5a38f7 100644 --- a/studio/backend/core/export/export.py +++ b/studio/backend/core/export/export.py @@ -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)) diff --git a/tests/studio/test_studio_gguf_export_script_pin.py b/tests/studio/test_studio_gguf_export_script_pin.py new file mode 100644 index 0000000000..12aaaafff4 --- /dev/null +++ b/tests/studio/test_studio_gguf_export_script_pin.py @@ -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"