unsloth/studio/backend/utils/uv_path_safety.py
Daniel Han 3a58fa5c41
Studio: apply base.txt on the install.sh and install.ps1 paths (#8195)
* Studio: apply base.txt on the install.sh and install.ps1 paths

install.sh and install.ps1 install unsloth and unsloth-zoo inline, then
export SKIP_STUDIO_BASE=1 so setup.sh / setup.ps1 do not install the same
two packages a second time. install_python_stack.py read that flag as
"skip base.txt" and short-circuited the whole step:

    if skip_base:
        pass

That was the same thing only for as long as base.txt held nothing but
those two names. Add a third, pinned entry to base.txt and it reaches no
fresh install on any platform: neither installer reads the file, and the
one branch that does was skipped. It would only land later, if the user
happened to run `unsloth studio update`.

Every install.sh and install.ps1 path was affected, on every platform:
CUDA, ROCm, XPU, CPU, macOS, local and non-local, fresh and migrated.

Keep skipping the two core packages, which is all the flag was ever
meant to avoid repeating, and apply whatever else base.txt asks for.
When base.txt holds only the core packages, as it does today, there is
nothing left to install and no extra subprocess runs. No-torch mode is
untouched: it has its own list in no-torch-runtime.txt, which the
installers do apply inline.

The core-package filter parses the project name rather than matching on
a prefix, so a future unsloth-<something> pin is not swallowed too.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Reconcile base requirements with current main

* Preserve relative requirements includes across filters

* Fix filtered requirements test cleanup

* Separate core and shared base requirements

* Preserve shared base requirement resolution

* Keep the filtered-requirements and uv alias paths from aborting an install

The adjacent temp copy raised PermissionError on a read-only requirements dir, and a symlink failure handed uv back the spaced path it cannot read. Fall back to the temp dir and to a copy respectively, and stop the real-extras tests leaving filtered files in the tree.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Count the MLX slot and survive an unusable base.txt

Simulating every install path showed two gaps. base_total never counted the Apple Silicon MLX step, so `studio update` there ran 13 steps out of a declared 12 and recorded the wrong steps_total. And the new base.txt read happens before the manifest is dropped, so a missing or unreadable file aborted with a traceback where the old code reached pip; a BOM also read as content and scheduled an empty step. Progress coverage now spans both core paths on all four platforms.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Make the unreadable base.txt case independent of the mode bits

chmod(0o000) denies nothing as root, which containerized test jobs run as, and Windows does not implement POSIX modes at all, so the case asserted None against a file it could still read. Raise from a patched read instead.

* Tighten the comments this PR adds

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
2026-08-11 00:06:23 -07:00

79 lines
2.8 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Hand uv a space-free `-c`/`--override`/`-r` file path (issue #6503).
uv splits `-c`/`--override` (and UV_OVERRIDE) on whitespace, so a path with a
space truncates. Windows uses the 8.3 short form; POSIX uses a space-free temp
path (removed at exit). Falls back to the original path on error. Shared by
install_python_stack and utils.mlx_repair.
"""
from __future__ import annotations
import atexit
import os
import platform
import shutil
import tempfile
IS_WINDOWS = platform.system() == "Windows"
_UV_SAFE_PATH_TMPDIRS: list[str] = []
@atexit.register
def _cleanup_uv_safe_path_tmpdirs() -> None:
while _UV_SAFE_PATH_TMPDIRS:
shutil.rmtree(_UV_SAFE_PATH_TMPDIRS.pop(), ignore_errors = True)
def uv_safe_path(path: object) -> str:
s = str(path)
if " " not in s:
return s
if IS_WINDOWS:
try:
import ctypes
from ctypes import wintypes
get_short = ctypes.windll.kernel32.GetShortPathNameW
get_short.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD]
get_short.restype = wintypes.DWORD
buf = ctypes.create_unicode_buffer(32768)
rc = get_short(s, buf, 32768)
if 0 < rc < 32768 and " " not in buf.value:
return buf.value
except Exception:
pass
return s
tmp_dir = None
try:
if not os.path.isfile(s):
return s
tmp_dir = tempfile.mkdtemp(prefix = "unsloth_uv_")
if " " in tmp_dir: # e.g. TMPDIR itself has a space
shutil.rmtree(tmp_dir, ignore_errors = True)
return s
source_name = os.path.basename(s) or "uv_args.txt"
if " " in source_name:
dst = os.path.join(tmp_dir, source_name.replace(" ", "_"))
shutil.copyfile(s, dst)
else:
alias_dir = os.path.join(tmp_dir, "source")
source_dir = os.path.abspath(os.path.dirname(s) or os.curdir)
try:
os.symlink(source_dir, alias_dir, target_is_directory = True)
dst = os.path.join(alias_dir, source_name)
except OSError:
# No symlink permission: copy instead. That loses relative -r/-c
# includes, but returning the spaced path loses the file entirely.
dst = os.path.join(tmp_dir, source_name)
shutil.copyfile(s, dst)
_UV_SAFE_PATH_TMPDIRS.append(tmp_dir)
tmp_dir = None
return dst
except Exception:
if tmp_dir is not None: # don't leak the temp dir if the copy failed
shutil.rmtree(tmp_dir, ignore_errors = True)
return s