mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-16 12:24:06 +00:00
* Report unsloth's own version on the MLX path The MLX branch of unsloth/__init__.py set __version__ from unsloth_zoo, a different package pinned with >= rather than ==, so unsloth.__version__ on Apple Silicon reported a number that was neither the installed core nor the latest zoo. It also disagreed with pip show unsloth, unsloth --version and Studio's own version on the same machine. Move the literal into unsloth/_version.py, a leaf module with no imports, and have every consumer read it from there. The MLX path can import it without reaching unsloth.models._utils, which pulls in torch, transformers, trl, peft and the Triton kernels and would defeat that branch's torch-free boot. pyproject keeps deriving the distribution version from it, and setuptools still resolves it with a static AST parse, so building a wheel never imports torch. models/_utils.py re-exports the name, so existing consumers are unchanged. Studio's get_unsloth_version fallback scans for a __version__ = line and now points at the file that still has one, else a source checkout reports dev. The GPU path already reported the core's own version and is unaffected. Fixes #8171 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep the version fallback working on a half-updated tree get_unsloth_version falls back to scanning the source when distribution metadata is missing, which is the source-checkout case. The scan matches a __version__ = line prefix, and moving the literal left models/_utils.py holding only a re-export, which that prefix does not match. Pointing the scan at _version.py alone is correct for a current tree but reports dev on any tree where the two files disagree, which a partial unsloth studio update can produce. Try both files in order, so either one alone still yields a real version. The test that pinned this was matching main.py's source with a regex, which broke on the very refactor it was meant to guard. Replace it with one that runs the real function against the real tree with metadata forced absent, and add the half-updated layouts. Verified against real installs: wheel, sdist, editable and source checkout all report 2026.8.12, and neither-file-current still reports dev rather than a wrong number. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten the comments on the version change --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
195 lines
8.5 KiB
Python
195 lines
8.5 KiB
Python
# Copyright 2023-present Daniel Han-Chen & the Unsloth team. All rights reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Pure-CPU, no-network tests pinning unsloth/_version.py as the one source of the version.
|
|
|
|
Three consumers read that literal by different means and each breaks differently when it
|
|
moves: pyproject resolves it with a static AST parse (a non-literal makes setuptools import
|
|
torch inside the build env), Studio's version fallback scans the file line by line for
|
|
``__version__ = `` (a re-export line silently reports "dev"), and the MLX branch of
|
|
unsloth/__init__.py imports the module directly (anything with imports defeats its
|
|
torch-free boot, which is what drove it to borrow unsloth_zoo's number instead -- unsloth#8171).
|
|
|
|
None of that is visible from `unsloth.__version__` on a GPU host, so it is pinned here.
|
|
"""
|
|
|
|
import ast
|
|
import re
|
|
import sys
|
|
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
def _raise_not_found(name):
|
|
raise _PackageNotFoundError(name)
|
|
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
_VERSION_FILE = _REPO_ROOT / "unsloth" / "_version.py"
|
|
|
|
_HEAVY = ("torch", "transformers", "trl", "peft", "triton", "unsloth_zoo")
|
|
|
|
|
|
def _load_version_module_standalone():
|
|
"""Load _version.py off disk, bypassing the unsloth package __init__."""
|
|
import importlib.util
|
|
|
|
spec = importlib.util.spec_from_file_location("_unsloth_version_probe", _VERSION_FILE)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def test_the_version_file_is_a_literal_in_a_module_with_no_imports():
|
|
tree = ast.parse(_VERSION_FILE.read_text(encoding = "utf-8"))
|
|
|
|
imports = [n for n in ast.walk(tree) if isinstance(n, (ast.Import, ast.ImportFrom))]
|
|
assert imports == [], (
|
|
"unsloth/_version.py must import nothing: pyproject reads it with a static AST "
|
|
"parse, and the torch-free MLX path imports it directly."
|
|
)
|
|
|
|
assigns = [n for n in tree.body if isinstance(n, ast.Assign)]
|
|
assert len(assigns) == 1, "the module should hold the version and nothing else"
|
|
target, value = assigns[0].targets[0], assigns[0].value
|
|
assert target.id == "__version__"
|
|
assert isinstance(value, ast.Constant) and isinstance(value.value, str), (
|
|
"__version__ must stay a plain string literal, else setuptools falls back to "
|
|
"importing unsloth.models._utils (and torch) at build time."
|
|
)
|
|
|
|
|
|
def test_importing_the_version_alone_pulls_in_no_heavy_dependency():
|
|
before = {name for name in _HEAVY if name in sys.modules}
|
|
module = _load_version_module_standalone()
|
|
after = {name for name in _HEAVY if name in sys.modules}
|
|
|
|
assert isinstance(module.__version__, str) and module.__version__
|
|
assert after - before == set(), "reading the version must not import the world"
|
|
|
|
|
|
def test_models_utils_still_re_exports_the_same_version():
|
|
# Every banner, every saved config's unsloth_version, and unsloth.__version__ on the
|
|
# GPU path come through here.
|
|
from unsloth.models._utils import __version__ as via_utils
|
|
assert via_utils == _load_version_module_standalone().__version__
|
|
|
|
|
|
def test_pyproject_reads_the_version_from_the_leaf_module():
|
|
pyproject = (_REPO_ROOT / "pyproject.toml").read_text(encoding = "utf-8")
|
|
match = re.search(r"^version\s*=\s*\{attr\s*=\s*\"([^\"]+)\"\}", pyproject, re.MULTILINE)
|
|
assert match, "pyproject must keep deriving the distribution version from an attr"
|
|
assert match.group(1) == "unsloth._version.__version__"
|
|
|
|
|
|
def test_setuptools_resolves_the_version_without_importing_torch():
|
|
read_attr = pytest.importorskip("setuptools.config.expand").read_attr
|
|
|
|
before = {name for name in _HEAVY if name in sys.modules}
|
|
resolved = read_attr("unsloth._version.__version__", root_dir = str(_REPO_ROOT))
|
|
after = {name for name in _HEAVY if name in sys.modules}
|
|
|
|
assert resolved == _load_version_module_standalone().__version__
|
|
assert after - before == set(), (
|
|
"the build must resolve the version statically; importing unsloth.models._utils "
|
|
"here would drag torch into the build environment."
|
|
)
|
|
|
|
|
|
def _get_unsloth_version_with_metadata_missing(main_py_path):
|
|
"""Run studio/backend/main.py::get_unsloth_version with the distribution metadata
|
|
forced absent, which is the source-checkout case its file scan exists for.
|
|
|
|
The function body is exec'd rather than imported because importing main.py starts the
|
|
whole FastAPI backend.
|
|
"""
|
|
src = _Path_read(main_py_path)
|
|
start = src.index("def get_unsloth_version()")
|
|
body = src[start : src.index("\n\n\n", start)]
|
|
namespace = {
|
|
"_Path": Path,
|
|
"PackageNotFoundError": _PackageNotFoundError,
|
|
"package_version": _raise_not_found,
|
|
"__file__": str(main_py_path),
|
|
}
|
|
exec(compile(body, "main.py", "exec"), namespace)
|
|
return namespace["get_unsloth_version"]()
|
|
|
|
|
|
def _Path_read(p):
|
|
return Path(p).read_text(encoding = "utf-8")
|
|
|
|
|
|
def test_studio_version_fallback_reports_the_real_version_on_a_source_checkout():
|
|
# get_unsloth_version falls back to scanning the source when distribution metadata is
|
|
# missing. The scan matches a `__version__ = ` line prefix, so a file that only
|
|
# re-exports the name yields nothing and Studio silently reports "dev". Driving the
|
|
# real function against the real tree catches that whatever the scan is pointed at.
|
|
reported = _get_unsloth_version_with_metadata_missing(
|
|
_REPO_ROOT / "studio" / "backend" / "main.py"
|
|
)
|
|
assert reported == _load_version_module_standalone().__version__, (
|
|
f"the fallback reported {reported!r}; Studio would show that instead of the "
|
|
"real version on a source checkout."
|
|
)
|
|
|
|
|
|
def test_the_studio_fallback_survives_a_half_updated_tree(tmp_path):
|
|
# The fallback is what a source checkout relies on, and a checkout can be half
|
|
# updated: `unsloth studio update` pulls a repo, and a stale tree can carry a new
|
|
# main.py beside an old models/_utils.py or the reverse. Either file alone must still
|
|
# yield a real version rather than "dev".
|
|
import re as _re
|
|
|
|
main_py = (_REPO_ROOT / "studio" / "backend" / "main.py").read_text(encoding = "utf-8")
|
|
start = main_py.index("def get_unsloth_version()")
|
|
body = main_py[start : main_py.index("\n\n\n", start)]
|
|
|
|
def _version_for(layout):
|
|
root = tmp_path / layout
|
|
(root / "unsloth" / "models").mkdir(parents = True)
|
|
(root / "studio" / "backend").mkdir(parents = True)
|
|
if layout in ("current", "only_version"):
|
|
(root / "unsloth" / "_version.py").write_text('__version__ = "9.9.9"\n')
|
|
if layout == "only_utils":
|
|
(root / "unsloth" / "models" / "_utils.py").write_text('__version__ = "9.9.9"\n')
|
|
else:
|
|
(root / "unsloth" / "models" / "_utils.py").write_text(
|
|
"from .._version import __version__\n"
|
|
)
|
|
namespace = {
|
|
"_Path": Path,
|
|
"PackageNotFoundError": _PackageNotFoundError,
|
|
# Force the metadata lookup to miss, which is the source-checkout case.
|
|
"package_version": _raise_not_found,
|
|
"__file__": str(root / "studio" / "backend" / "main.py"),
|
|
}
|
|
exec(compile(body, "main.py", "exec"), namespace)
|
|
return namespace["get_unsloth_version"]()
|
|
|
|
assert _version_for("current") == "9.9.9"
|
|
assert _version_for("only_version") == "9.9.9"
|
|
assert _version_for("only_utils") == "9.9.9"
|
|
# Neither file carries a literal: reporting "dev" is correct, not a silent wrong number.
|
|
assert _version_for("neither") == "dev"
|
|
|
|
|
|
def test_the_mlx_branch_no_longer_borrows_the_zoo_version():
|
|
# unsloth#8171: the MLX path reported unsloth_zoo's number, which is a different
|
|
# package pinned with >=, so it was neither the installed core nor the latest zoo.
|
|
init_py = (_REPO_ROOT / "unsloth" / "__init__.py").read_text(encoding = "utf-8")
|
|
assert "__version__ = unsloth_zoo.__version__" not in init_py
|
|
assert "from ._version import __version__" in init_py
|