mirror of
https://github.com/unslothai/unsloth.git
synced 2026-07-10 16:28:44 +00:00
* Copy uv `-c`/`-r` paths to a space-free temp dir on macOS/Linux
uv 0.11.x truncates a constraints/requirements path passed via `-c`/`-r`
at the first space, so `unsloth studio` setup from a repo cloned under a
path containing a space (e.g. `/Users/me/Open Source/unsloth`) fails with:
error: File not found: `/Users/me/Open`
_uv_safe_path() already worked around this on Windows via the 8.3 short
path but returned the space-containing path unchanged on macOS/Linux,
which have no 8.3 equivalent. Extend it to copy the (small, flat)
constraints/requirements file into a space-free temp dir and hand uv the
copy; the temp dirs are removed at process exit. Falls back to the
original path on any error, so it is never worse than before.
Refs unslothai/unsloth#6503
* Route UV_OVERRIDE through _uv_safe_path and fix temp-dir leak (#6503)
The -c/-r fix did not cover UV_OVERRIDE, which uv also truncates at the first
space. On Apple Silicon the overrides file is handed to uv via UV_OVERRIDE at
install time (install_python_stack.py) and during the MLX self-heal
(utils.mlx_repair), so a repo under a path containing a space still broke every
uv call there. Move _uv_safe_path into backend.utils.uv_path_safety so both
sites share it, and route UV_OVERRIDE through it.
Also stop leaking the temp dir when shutil.copyfile fails after mkdtemp, and add
tests for the UV_OVERRIDE channel, the TMPDIR-with-space fallback, the atexit
cleanup, and the no-leak path.
---------
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
150 lines
5.8 KiB
Python
150 lines
5.8 KiB
Python
"""Tests for install_python_stack._build_uv_cmd torch-backend handling."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import glob
|
|
import importlib
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
STUDIO_DIR = Path(__file__).resolve().parents[2] / "studio"
|
|
sys.path.insert(0, str(STUDIO_DIR))
|
|
|
|
import install_python_stack as ips
|
|
|
|
|
|
class TestBuildUvCmdTorchBackend:
|
|
"""Verify _build_uv_cmd only adds --torch-backend when UV_TORCH_BACKEND is set."""
|
|
|
|
def _call(self, args: tuple[str, ...] = ()) -> list[str]:
|
|
return ips._build_uv_cmd(args)
|
|
|
|
def test_default_no_torch_backend(self):
|
|
"""Without UV_TORCH_BACKEND env var, no --torch-backend flag."""
|
|
env = os.environ.copy()
|
|
env.pop("UV_TORCH_BACKEND", None)
|
|
with mock.patch.dict(os.environ, env, clear = True):
|
|
cmd = self._call(("somepackage",))
|
|
assert not any(
|
|
a.startswith("--torch-backend") for a in cmd
|
|
), f"--torch-backend should not appear by default, got: {cmd}"
|
|
|
|
def test_uv_torch_backend_auto(self):
|
|
"""UV_TORCH_BACKEND=auto adds --torch-backend=auto."""
|
|
with mock.patch.dict(os.environ, {"UV_TORCH_BACKEND": "auto"}):
|
|
cmd = self._call(("somepackage",))
|
|
assert "--torch-backend=auto" in cmd
|
|
|
|
def test_uv_torch_backend_cpu(self):
|
|
"""UV_TORCH_BACKEND=cpu adds --torch-backend=cpu."""
|
|
with mock.patch.dict(os.environ, {"UV_TORCH_BACKEND": "cpu"}):
|
|
cmd = self._call(("somepackage",))
|
|
assert "--torch-backend=cpu" in cmd
|
|
|
|
def test_uv_torch_backend_empty(self):
|
|
"""UV_TORCH_BACKEND="" (empty string) should NOT add --torch-backend."""
|
|
with mock.patch.dict(os.environ, {"UV_TORCH_BACKEND": ""}):
|
|
cmd = self._call(("somepackage",))
|
|
assert not any(
|
|
a.startswith("--torch-backend") for a in cmd
|
|
), f"Empty UV_TORCH_BACKEND should not add flag, got: {cmd}"
|
|
|
|
|
|
class TestUvSafePath:
|
|
"""_uv_safe_path hands uv a space-free `-c`/`-r` path (issue #6503)."""
|
|
|
|
def test_passthrough_when_no_space(self):
|
|
"""A path without a space is returned unchanged on every platform."""
|
|
p = "/tmp/plain/constraints.txt"
|
|
assert ips._uv_safe_path(p) == p
|
|
|
|
@pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback")
|
|
def test_posix_space_path_returns_spacefree_copy(self, tmp_path):
|
|
src = tmp_path / "Open Source" / "constraints.txt"
|
|
src.parent.mkdir(parents = True)
|
|
src.write_text("torch>=2.6\n")
|
|
|
|
out = ips._uv_safe_path(str(src))
|
|
|
|
assert " " not in out, f"uv-safe path still has a space: {out!r}"
|
|
assert out != str(src)
|
|
assert Path(out).read_text() == "torch>=2.6\n"
|
|
|
|
@pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback")
|
|
def test_posix_missing_file_falls_back_to_original(self):
|
|
"""No file to copy -> return the original path rather than raise."""
|
|
p = "/nonexistent dir/constraints.txt"
|
|
assert ips._uv_safe_path(p) == p
|
|
|
|
|
|
class TestUvSafePathHardening:
|
|
"""Edge cases for uv_safe_path + the UV_OVERRIDE channel (issue #6503)."""
|
|
|
|
@pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback")
|
|
def test_tmpdir_with_space_falls_back(self, tmp_path, monkeypatch):
|
|
"""A space in the temp root itself -> fall back to the original path."""
|
|
from backend.utils import uv_path_safety as uvps
|
|
|
|
spaced = tmp_path / "tmp dir with space"
|
|
spaced.mkdir()
|
|
monkeypatch.setattr(uvps.tempfile, "mkdtemp", lambda *a, **k: str(spaced))
|
|
src = tmp_path / "Open Source" / "constraints.txt"
|
|
src.parent.mkdir(parents = True)
|
|
src.write_text("idna\n")
|
|
assert uvps.uv_safe_path(str(src)) == str(src)
|
|
|
|
@pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback")
|
|
def test_no_temp_dir_leak_on_copy_failure(self, tmp_path, monkeypatch):
|
|
"""A copyfile failure after mkdtemp must not orphan the temp dir."""
|
|
from backend.utils import uv_path_safety as uvps
|
|
|
|
src = tmp_path / "Open Source" / "constraints.txt"
|
|
src.parent.mkdir(parents = True)
|
|
src.write_text("idna\n")
|
|
pattern = os.path.join(tempfile.gettempdir(), "unsloth_uv_*")
|
|
before = set(glob.glob(pattern))
|
|
|
|
def boom(*a, **k):
|
|
raise OSError("boom")
|
|
|
|
monkeypatch.setattr(uvps.shutil, "copyfile", boom)
|
|
out = uvps.uv_safe_path(str(src))
|
|
|
|
assert out == str(src)
|
|
assert set(glob.glob(pattern)) == before
|
|
|
|
@pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback")
|
|
def test_cleanup_removes_and_clears_registry(self, tmp_path):
|
|
"""The atexit-registered cleanup removes the copies and empties the list."""
|
|
from backend.utils import uv_path_safety as uvps
|
|
|
|
src = tmp_path / "Open Source" / "constraints.txt"
|
|
src.parent.mkdir(parents = True)
|
|
src.write_text("idna\n")
|
|
out = uvps.uv_safe_path(str(src))
|
|
tmp_dir = Path(out).parent
|
|
assert tmp_dir.is_dir() and str(tmp_dir) in uvps._UV_SAFE_PATH_TMPDIRS
|
|
|
|
uvps._cleanup_uv_safe_path_tmpdirs()
|
|
|
|
assert not tmp_dir.exists()
|
|
assert uvps._UV_SAFE_PATH_TMPDIRS == []
|
|
|
|
@pytest.mark.skipif(ips.IS_WINDOWS, reason = "POSIX temp-copy fallback")
|
|
def test_uv_override_value_is_space_safe(self, tmp_path):
|
|
"""The value stored for UV_OVERRIDE must be space-free."""
|
|
from backend.utils import uv_path_safety as uvps
|
|
|
|
overrides = tmp_path / "Open Source" / "overrides-darwin-arm64.txt"
|
|
overrides.parent.mkdir(parents = True)
|
|
overrides.write_text("transformers>=4.57.6\n")
|
|
|
|
value = uvps.uv_safe_path(overrides)
|
|
|
|
assert " " not in value
|
|
assert Path(value).read_text() == "transformers>=4.57.6\n"
|