From b508c8fe89a60df5995aeafe0c17589a93cecd50 Mon Sep 17 00:00:00 2001 From: Andrew Chen <48723787+chuenchen309@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:55:34 +0800 Subject: [PATCH] fix(save): unsloth_push_to_hub_gguf(save_method="lora") raises NameError (#7193) * fix(save): unsloth_push_to_hub_gguf(save_method="lora") raises NameError unsloth_push_to_hub_gguf reads is_main_process at save.py:3181 but never declares it. Its twin unsloth_save_pretrained_gguf declares it (2783) and uses it the same way (2839) -- the LoRA branch was copied between the twins, the parameter it depends on was not. There is no module-level global, so the name resolves as a global load and the branch raises NameError 100% of the time. save_pretrained_gguf(save_method="lora", push_to_hub=True) raises a ValueError that tells users to "use .push_to_hub_gguf(save_method='lora') instead" -- the documented escape hatch is the broken call. Add is_main_process to the signature, positioned as in the twin, and forward it to unsloth_save_pretrained_gguf on the merged path so the parameter is not silently ignored there. Default stays True, so nothing changes for existing callers. Co-Authored-By: Claude Opus 4.8 * fix(save): preserve GGUF push compatibility --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Etherll <61019402+Etherll@users.noreply.github.com> --- tests/saving/test_export_dispatch.py | 76 ++++++++++++++++++++++++++++ unsloth/save.py | 6 ++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/tests/saving/test_export_dispatch.py b/tests/saving/test_export_dispatch.py index 3870d6269d..ad6d51e0b7 100644 --- a/tests/saving/test_export_dispatch.py +++ b/tests/saving/test_export_dispatch.py @@ -8,6 +8,8 @@ regressions that pure AST checks cannot (e.g. wrong scheme/suffix/outtype passed from __future__ import annotations +import inspect + import pytest import unsloth.save as save_mod @@ -126,6 +128,80 @@ def test_gguf_lora_push_to_hub_is_rejected(tmp_path): ) +# The above rejection points users at push_to_hub_gguf(save_method='lora'), so that path +# has to work; it is only ever exercised here. + + +def test_push_to_hub_gguf_lora_dispatches(monkeypatch): + seen = {} + monkeypatch.setattr( + save_mod, + "_unsloth_save_lora_gguf", + lambda model, tok, sd, **kw: seen.update(kw), + ) + save_mod.unsloth_push_to_hub_gguf( + _FakeModel(), + "repo/id", + tokenizer = object(), + save_method = "lora", + quantization_method = "q8_0", + ) + assert seen.get("outtype") == "q8_0" + assert seen.get("push_to_hub") is True + + +def test_push_to_hub_gguf_lora_skips_non_main_process(monkeypatch): + calls = [] + monkeypatch.setattr( + save_mod, + "_unsloth_save_lora_gguf", + lambda *a, **kw: calls.append(kw), + ) + result = save_mod.unsloth_push_to_hub_gguf( + _FakeModel(), + "repo/id", + tokenizer = object(), + save_method = "lora", + is_main_process = False, + ) + assert result is None + assert calls == [] + + +def test_push_to_hub_gguf_skips_non_main_process_before_merged_conversion(monkeypatch): + calls = [] + monkeypatch.setattr( + save_mod, + "unsloth_save_pretrained_gguf", + lambda **kw: calls.append(kw), + ) + result = save_mod.unsloth_push_to_hub_gguf( + _FakeModel(), + "repo/id", + tokenizer = object(), + is_main_process = False, + ) + assert result is None + assert calls == [] + + +def test_push_to_hub_gguf_preserves_positional_max_shard_size(): + bound = inspect.signature(save_mod.unsloth_push_to_hub_gguf).bind( + _FakeModel(), + "repo/id", + object(), + "q4_k_m", + None, + None, + None, + None, + "token", + "50GB", + ) + assert bound.arguments["max_shard_size"] == "50GB" + assert "is_main_process" not in bound.arguments + + # -- torchao PTQ / QAT dispatch ------------------------------------------------------------ diff --git a/unsloth/save.py b/unsloth/save.py index 50a180f823..7d5774aa97 100644 --- a/unsloth/save.py +++ b/unsloth/save.py @@ -3143,6 +3143,7 @@ def unsloth_push_to_hub_gguf( datasets: Optional[List[str]] = None, save_method: str = None, imatrix_file = None, + is_main_process: bool = True, ): """ Same as .push_to_hub(...) except 4bit weights are auto @@ -3175,11 +3176,11 @@ def unsloth_push_to_hub_gguf( """ if tokenizer is None: raise ValueError("Unsloth: Saving to GGUF must have a tokenizer.") + if not is_main_process: + return None # save_method="lora" exports the adapter itself as a GGUF LoRA (not a merged model). if save_method is not None and str(save_method).lower() == "lora": - if not is_main_process: - return None # only the main rank converts and uploads, like the local lora branch _qm = quantization_method if isinstance(_qm, (list, tuple)) and len(_qm) == 1: _qm = _qm[0] # the gguf API allows a list; unwrap a single outtype @@ -3233,6 +3234,7 @@ def unsloth_push_to_hub_gguf( first_conversion = first_conversion, push_to_hub = False, # Never push from here token = token, # forwarded so imatrix_file=True can read a gated/private upstream + is_main_process = is_main_process, max_shard_size = max_shard_size, safe_serialization = safe_serialization, temporary_location = temporary_location,