mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-25 08:42:25 +00:00
* Studio: repair duplicate package metadata during updates * Studio: clarify duplicate metadata recovery * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: preserve metadata repair across install paths * Studio: complete duplicate metadata repair * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: harden duplicate metadata repair * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: reject unreadable package metadata * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: simplify metadata conflict handling * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: protect malformed metadata repair on Windows * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: verify editable foreign environments * Studio: reject versionless foreign metadata * Studio: repair malformed package metadata * Studio: do not read one site-packages twice on a lib64 build purelib hardcodes lib while platlib follows sys.platlibdir, and venv creates lib64 as a symlink to lib. On Fedora and SuSE the two schemes are therefore two names for one directory, and deduplicating the scan roots by string kept both. installed_versions() then returned every record twice, so metadata_conflict() was true for every installed package in a perfectly healthy environment: update would exit 1 telling the user to recreate the venv, and the repair could never converge because the duplication was an illusion. Deduplicate on the real path instead. * Studio: apply the same scan rules to the CLI's own metadata checks Two changes to _studio_deps, both keeping it consistent with install_manifest: _scan_paths and _venv_site_packages deduplicate their site-packages roots by real path, so a lib64 build does not report every installed package as duplicated, locally or when inspecting a foreign managed venv. installed_metadata_conflicts grouped on METADATA's Name and dropped records without one, so a nameless or non-UTF-8 record was invisible to it while installed_versions() and _distributions_in() both called the same directory a conflict. It now applies the dist-info filename fallback the manifest already uses. Such a record is marked unreadable rather than trusted, so damaged_installed_files keeps skipping it: pip cannot parse it either. * Studio: fetch the replacement before removing any metadata record The uninstall loop removes every record it finds, and the reinstall went through pip_install, which routes failure into run() and exits the process. So a failure between the two left the venv with no unsloth at all, took the entry point with it, and never reached the function's own return False. An unreachable index was one way in; --package with a name that is not on an index was certain to hit it, since such a package can never be reinstalled by bare name. Stage the replacement with pip download --no-deps first, install from that directory, and report a failed uninstall or install instead of exiting, so the caller's return 1 runs and the existing install is left alone when the replacement cannot be obtained. * Studio: probe for duplicate metadata in install.sh too install.ps1 and both setup scripts were moved onto installed_version_probe, but install.sh still reported the installed version through importlib.metadata.version(), which answers from whichever record the finder yields first. The same duplicated venv was therefore reported as a conflict on Windows and as an ordinary version on Linux and macOS. Both installers also fall back to the plain version lookup when studio.install_manifest cannot be imported, which is the case when --package installs something that does not ship studio/: that used to print the version and would otherwise have started warning that it could not be determined. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: make a failed metadata repair non-destructive in both directions Two gaps in the staging I added, both reproduced. An unreadable record has to be out of the tree before pip runs, since a non-UTF-8 METADATA makes pip list, show and uninstall raise for the whole environment. It was deleted outright, so a package whose ONLY record was unreadable lost it and was left with files and no install, while the message said the existing install had been left in place. It is now moved to a quarantine directory and put back on any outcome short of a completed repair. pip download leaves an sdist when the source has no compatible wheel, and the install that follows runs --no-index, so its isolated build could not fetch setuptools and the package stayed uninstalled. Stage with pip wheel instead, which builds while the index is still reachable, and require a wheel to be present before treating the package as staged. * Studio: apply the repo's kwarg-spacing formatter to the metadata repair changes * Studio: stage overlay sources too, and put the payload back on a partial uninstall Three gaps, each reproduced. --local pulls unsloth-zoo from git, so an overlay is a network fetch just as much as an index install is, yet the overlay path skipped staging entirely. An unreachable GitHub therefore left unsloth-zoo uninstalled after the loop had already removed every record. Stage whichever source will be installed, and let a failed overlay fall back to the staged artifact, which was built from that same source so it never substitutes a release. The uninstall loop returned as soon as one uninstall failed, even though an earlier successful one had already deleted the package tree, leaving a surviving dist-info that claimed an installed core package whose files were gone. It now reinstalls from the staged replacement first. installed_metadata_conflicts treated a record with a name but no version as readable, so a single such record was silently trusted while install_manifest.metadata_conflict called the same directory inconsistent. Readability now requires both, and the message distinguishes an unreadable record from a duplicated one. * Studio: build the foreign venv fixture at the platform's interpreter path The cross-venv tests write bin/python, which _venv_site_packages only looks at on POSIX. On Windows the probe has nothing to run, so the fixture falls through to the glob fallback and the resolution under test never happens: both new cases failed there while passing on Linux and macOS. Found by running these files on a windows-latest runner. The repo's Windows matrix covers three parity files only, so nothing else would have caught it. * Repair duplicate core metadata again before writing the manifest The repair pass runs before the core packages are installed, so an upgrade that itself leaves a superseded .dist-info behind survived it. write_manifest then recorded a null version and the installer exited successfully while every later check rejected the environment. Run the same repair a second time immediately before write_manifest. It is a no-op when nothing is ambiguous. * Keep the metadata repair working under uv index and hash policies Three ways a hardened or privately mirrored machine could defeat the duplicate-metadata repair: - pip wheel obeys require-hashes exactly as pip install does, so staging a replacement was refused before anything was built and the repair aborted on the conflict it exists to remove. Confirmed on pip 26.2. _relaxed_pip_policy_env now covers wheel alongside install and download. - The staged wheel was reinstalled through uv when uv is the package manager, and UV_REQUIRE_HASHES rejects the unpinned name after the uninstall loop has removed every record, leaving the package gone. Both staged reinstalls force pip; the wheel is already built. - uv has no wheel subcommand, so staging runs pip, which reads none of UV_DEFAULT_INDEX, UV_INDEX, UV_INDEX_URL, UV_EXTRA_INDEX_URL or UV_FIND_LINKS. A private build could be uninstalled and replaced by a public PyPI wheel. Those are now translated into the pip equivalents, and only where pip has no setting of its own. * Keep uv index priority and upload cutoff when staging a repair wheel Staging runs pip because uv has no wheel subcommand, and the previous commit handed uv's indexes across as PIP_INDEX_URL plus PIP_EXTRA_INDEX_URL. That restores the reach but not the semantics. uv's default index-strategy is first-index: it stops at the first index carrying the package, which is what stops a public release shadowing a private one. pip pools its indexes and takes the highest version, so a newer public wheel could replace a private build. Offer each index alone instead, in uv's own priority order, and take the first that can build the wheel. An inherited extra index is cleared for those attempts so the candidates stay separated. UV_EXCLUDE_NEWER was ignored the same way. pip's --uploaded-prior-to is the same upload-time filter and takes the same date spellings, so the cutoff is passed through. It only exists from pip 25.3; on an older pip staging refuses rather than install a wheel the cutoff forbids, which leaves the existing installation intact. * Only advance past an index that confirmed the package is absent Staging offers each uv index alone, in uv's priority order. The loop treated every failure as absence, so a private index that was merely unreachable or refused the credentials advanced to the public fallback, which is the dependency confusion first-index exists to prevent. pip cannot be asked directly: measured on pip 26.2, a refused connection and an unresolvable host both print the same two no-match ERROR lines a genuine absence does, above a WARNING about retrying after a broken connection, and never the Could not fetch URL string. So a no-match is only believed when no transport, authentication or TLS marker appears beside it. Anything else stops the loop with the install still intact. Also honour UV_INDEX_STRATEGY: unsafe-best-match really does pool every index and take the best version, which is pip's own default, so those staging attempts are pooled too. first-index and unsafe-first-match both exhaust one index before the next, which is what the loop already does. * Ask uv which release and index to stage instead of reconstructing them Staging must run pip, because uv has no wheel subcommand. Reading uv's index configuration out of the environment and translating it cannot be made correct: uv also discovers uv.toml, pyproject [tool.uv] and a user config, honours UV_CONFIG_FILE, applies an implicit PyPI default, and resolves under an index-strategy pip has no equivalent for. A private index configured any of those ways was invisible, so the repair could uninstall a private build and reinstall the public package. Ask uv instead. uv pip compile --emit-index-annotation reports the exact index each package resolved from, under uv's own discovery, priority, strategy and upload cutoff, and pip is then pointed at that one index with that one version, for this interpreter. An unreachable higher-priority index fails the compile outright, which is the behaviour first-index exists to give, so the pip output markers that approximated it are gone. UV_OFFLINE forbids network access and pip has no offline mode, so the repair refuses rather than reaching the network. Plain pip is unchanged: it already reads its own configuration, so there is nothing to ask. * Stage a direct reference as written and replace pip's candidate sources Two gaps in the uv-resolved staging path. The overlay paths hand staging a git URL or a local checkout rather than a bare name. Such a requirement is its own provenance, since no index chose it, and uv appends the resolved commit to what it emits, so the name comparison could never match and every local duplicate unsloth-zoo repair aborted before it started. A direct reference is now staged as written, without asking uv. Replaying uv's answer also has to replace pip's candidate sources rather than add to them. An inherited PIP_NO_INDEX blocked the index uv picked, and an inherited extra index or find-links directory could satisfy the same version from a source uv never looked at, which is the provenance swap this path exists to stop. pip.conf can carry the same three settings, so it is dropped for that one command exactly as the pinned-index branch already does. * Keep pip's transport config and replay uv's artifact policy when staging Dropping pip.conf to remove its candidate sources took proxy, cert, client-cert and trusted-host with it, and those are how a private index is reached at all, so uv could resolve and pip would then fail to fetch, leaving the repair blocked. The environment overrides cannot do this alone: measured on pip 26.2, an empty PIP_EXTRA_INDEX_URL does NOT suppress an extra-index-url set in pip.conf, and pip contacts that index exactly as it does with the variable unset. So pip's own configuration is asked for with pip config list and written back minus the four source keys, which leaves the transport settings in force. A no-binary or only-binary rule was also ignored, so the repair could download a wheel under a no-binary rule or build an sdist under an only-binary one. --emit-build-options carries the uv.toml form through. Measured on uv 0.10.7, it does not surface the environment-variable spelling, so UV_NO_BINARY and UV_ONLY_BINARY are translated by hand, and only where pip has no setting of its own. * Keep index credentials and let a local checkout stage while offline uv emits every configured index with its credentials, but strips userinfo from the annotation that says which one answered. Taking the annotation at face value handed pip an unauthenticated URL for a private index, which answers 401 and aborts the repair, and reading only --index-url named the wrong index entirely when the credentialed one was configured as an extra. The annotation is now matched back to the emitted URL that carries the credentials, preferring the credentialed form when one index is emitted both ways. The offline refusal also ran before the direct-reference branch, so --local could not repair a duplicate unsloth record even though its replacement is a checkout on disk that needs no network. It now applies only to sources that are not already local. A git reference is still a network fetch and is still refused. * Remove the old payload when every metadata record is unreadable Quarantining the only record left pip nothing to uninstall, so the staged wheel was laid over the existing tree and any module the new release dropped stayed on disk and importable while the repair reported success. Give pip a parseable METADATA beside the intact RECORD instead, so it removes exactly the files that record lists. Confirmed against a real venv: with the METADATA corrupted pip show raises UnicodeDecodeError for the whole environment, and after the rewrite pip uninstalls the package and its entire payload. Without a usable RECORD this fails closed and asks for the environment to be recreated, since nothing then knows which files belong to the package. A find-links origin also no longer displaces the index. uv annotates a flat source with a file:// URL, which belongs in PIP_FIND_LINKS, already set from the emitted find-links lines; an sdist taken from a flat directory still needs the real index for its build backend. * Build a local checkout without the network when uv is offline The offline exemption for a local checkout let staging through, but pip builds it in an isolated environment and fetches the build backend for that, which UV_OFFLINE does not reach. Measured: an isolated build of a local project with no index reachable fails at installing build dependencies, and this repository pins setuptools and setuptools-scm exactly, so they would be fetched unless already cached. Either way the no-network policy was broken or the repair failed for a reason the user could not see. Offline local staging now builds against the interpreter's own backend and forbids the index outright, so no-network means no network. Giving up isolation is how the pinned build requirements stop being honoured, so it happens only in that case; every other staging keeps it. If the backend is not importable the build fails and the installation is left intact. * Pin the staged artifact by hash and carry uv's no-index policy Suppressing pip's other candidate sources turned out not to be achievable. Measured: with PIP_CONFIG_FILE pointed at a sanitised copy, a venv's own pip.conf is still read and its extra-index-url is still contacted, and --isolated does not suppress it either. So pip may always consult a source uv never considered. Ask uv for hashes instead and let pip enforce them. Whatever source pip reaches, the artifact has to be the one uv resolved, which is a stronger guarantee than removing sources and does not depend on suppressing any configuration. Measured: pip verifies the hashes even with PIP_REQUIRE_HASHES=0, and rejects an artifact that does not match. They are only accepted from a requirements file, written into the staging directory so it is removed with it. A flat source resolved with no index in play is also what a configured no-index looks like on the way out: uv emits the find-links entry and no index line. PIP_NO_INDEX is now carried in that case rather than cleared, which had handed pip back the default PyPI. * Apply every usable RECORD and commit each package as it is repaired Quarantining an unreadable record drops its RECORD on the floor. Where one sat beside a readable duplicate, the uninstall loop then removed only what the readable record claimed, so a module existing solely in the older release stayed on disk and importable while the repair reported success. Every unreadable record that has a usable RECORD is now made parseable so pip uninstalls it normally; only what cannot be rewritten is moved aside, and if that leaves nothing to uninstall the repair fails closed as before. The quarantine was also shared across both core packages. If the first was reinstalled and the second then failed, the rollback restored the first package's stale record on top of the install that had already replaced it, reintroducing the conflict and leaving an old RECORD describing a payload that was gone. Each package now has its own, and it is discarded as soon as that package is back in place, so rollback only ever touches the package being repaired. * Back up rewritten metadata and accept a direct-reference pin from uv The rewrite has to happen before staging, and staging can still fail. Overwriting the original METADATA in place left no way back, and what remained parsed, so the next run would see one readable record, decide nothing was wrong, and never attempt the payload repair still owed. The quarantine now keeps a copy of every file it rewrites and puts it back on any unsuccessful exit, byte for byte, so the conflict is still detected. A record whose backup fails is quarantined instead of rewritten. uv also emits a PEP 508 direct reference rather than an == pin when an override redirects a package to a path, repository or URL. The whole line was being compared as the name, so the requirement stayed empty and every repair under that policy aborted. The name is now parsed from either spelling while the reference itself is staged as written. * Drop an import left unused by a replaced test The repo lint blocks on an import the PR adds but does not use. shutil was needed by a staging test that a later round replaced. * Pin the scan boundary so a record outside the venv cannot fail it _installed_version answered from all of sys.path before this branch and now scans purelib/platlib only. Simulated against a real pip --user install: the old lookup reports the version, the new one reports nothing. Unreachable in production, since every caller runs against the managed venv and Studio never creates one with system site-packages, but nothing pinned the intent. * Cover the two ways a duplicate record actually appears pip renames the outgoing distribution to a ~ prefixed sibling during an upgrade and a kill mid-operation keeps both, which is the documented real-world source of this state and had no test. The opposite case had none either: a setuptools editable install resolves to a dist-info in site-packages plus an egg-info in the checkout, and scanning purelib and platlib rather than all of sys.path is what stops that reading as a conflict. * Tighten four comment blocks without dropping any of their reasoning * Make an unusable METADATA repairable whether it is corrupt or absent invalid_metadata_paths returns Path, so _rewrite_minimal_metadata raised AttributeError on rstrip for every record whose METADATA was present but unparseable, which is the case the rewrite exists to handle. The surrounding tests passed str and hid it. An absent METADATA took a second route to the same end: back_up reported failure, the rewrite was skipped and the record was quarantined instead, so the uninstall loop removed only what the readable record claimed and a module shipped solely by the older release stayed on disk and importable while the repair reported success. An absent file is now nothing to back up rather than a failure, and restore removes the synthetic METADATA instead of reinstating one that never existed. * Carry uv's keyring provider into the staging pip command The staging plan already replays uv's chosen index and its userinfo, but an index authenticated through the keyring CLI reached pip as a bare URL, so pip could not fetch what uv had just resolved and every repair on a private index aborted. Verified against the installed tools: uv 0.10.7 exposes UV_KEYRING_PROVIDER with disabled and subprocess, and pip 26.2.1 accepts both as PIP_KEYRING_PROVIDER, so the existing artifact-policy translation covers it unchanged. A uv.toml keyring-provider is not emitted and stays out of reach, same as the artifact policy. * Move pip's abandoned backup aside so the uninstall loop can converge An interrupted pip upgrade leaves the outgoing distribution renamed to a ~ prefixed sibling. Its METADATA still names the project, so installed_versions counts it as a duplicate, but pip calls the directory an invalid distribution and skips it, so uninstalling by package name can never consume it. Measured in a real venv: pip logged Ignoring invalid distribution ~impkg, the loop hit remaining >= record_count, and the repair returned False with both records still present, which would abort every future update on the commonest real cause of this state. The backup is now found by directory name and quarantined before the loop, so a failure to stage still restores it. Reported by review. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fail closed on an unrecorded stale record even beside a readable one _rewrite_minimal_metadata already refuses a record with no RECORD, because nothing then knows which files that release owned. The repair only honoured that when no other record survived. With one surviving, the count stayed nonzero, pip uninstalled just the readable record, and the quarantine was discarded on success, so whatever the older release owned alone stayed importable and the directory that was the evidence was deleted for good. Measured before the change: repair returned True, the stale module was still importable, and the stale dist-info was gone. Now the repair stops with the directory untouched, so a later run can still see the conflict. Four tests built their malformed record without a RECORD and asserted the repair continued, which is the behaviour above. They use an unversioned directory name instead, so the rewrite still fails and the quarantine path they exist to cover is unchanged. Reported by review. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Refuse the repair for any record that cannot be made pip-uninstallable The previous commit only covered records with no RECORD. Any unrewritable record leaks the same way: quarantining it hides it, pip removes just the readable records, the quarantine is discarded once the reinstall succeeds, and whatever that release owned alone stays importable while the repair reports success and deletes the directory that was the evidence. The reachable route is a METADATA that exists but cannot be read, as an elevated install leaves root-owned. Measured in a real venv with the file unreadable: repair returned True, the stale module was still importable and its dist-info was gone. It now stops with the tree as found. An unreadable METADATA that can be rewritten still repairs, as does an ordinary duplicate. Quarantine-and-proceed had no remaining purpose once nothing runs after an unusable record, so it is gone and its three tests collapse into one that pins the invariant. Quarantine itself is still used for pip's ~ leftover, which is what the restore-on-staging-failure test now covers. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep the rollback's own metadata when the quarantine unwinds When the rewritten record has already been uninstalled and a later uninstall fails, _restore_from_staged reinstalls the package before the finally block runs quarantine.restore(). If the staged wheel recreates the same dist-info path, restore then either overwrote the wheel's valid METADATA with the original corrupt bytes or, where the original was absent, deleted a record pip had just written, leaving the core package malformed after a recovery whose whole purpose was to make it whole. Reproduced as a unit: the repair returned False and reading the resulting METADATA raised UnicodeDecodeError. A successful staged reinstall now drops the backed-up copies, since the wheel's metadata is authoritative. The moved entries are kept, because a record pip cannot consume still has to go back exactly as found. Reported by review. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Treat a sole pip backup as a metadata conflict for PR #8515 * Mark a foreign venv's sole pip backup as a conflict for PR #8515 * Tighten the docstrings this PR adds without dropping their reasoning --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com> Co-authored-by: danielhanchen <unslothshared@gmail.com>
541 lines
20 KiB
Python
541 lines
20 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
|
|
|
|
"""Install-completeness manifest for Unsloth Studio.
|
|
|
|
install_python_stack.py drops the manifest before the dependency pass and writes
|
|
it back only after the last step, so its presence means "the install finished".
|
|
Read by `unsloth studio verify-install`, `desktop-capabilities` (and through it
|
|
the Tauri preflight) and setup.sh/setup.ps1's fast path.
|
|
|
|
Without it an installer killed part-way leaves a venv with `unsloth` but not
|
|
studio.txt's dependencies, which still answers `-h` and so looked ready right up
|
|
until the backend died on `import structlog`.
|
|
|
|
Must import inside that half-installed venv: stdlib only, `packaging` optional.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import platform
|
|
import re
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Sequence, Tuple
|
|
|
|
MANIFEST_NAME = "unsloth_install_manifest.json"
|
|
MANIFEST_SCHEMA = 1
|
|
|
|
# Canonical truthy set for UNSLOTH_NO_TORCH, matching install.ps1 / install.sh.
|
|
NO_TORCH_TRUTHY: Tuple[str, ...] = ("1", "true", "yes", "on")
|
|
|
|
# Companion to the no_torch manifest key, next to setup.ps1's .unsloth-studio-owned.
|
|
# The manifest is deliberately dropped before every dependency pass, so it cannot
|
|
# answer for a run killed mid-pass; this marker is written before that pass and
|
|
# outlives it. Without it an interrupted GGUF-only install reads as a stale venv on
|
|
# the next update, which then tries to delete the venv it is running out of.
|
|
NO_TORCH_MARKER = ".unsloth-no-torch"
|
|
|
|
# Fingerprinted into the manifest, relative to studio/backend/requirements/.
|
|
# Editing one (a --local install) invalidates it and forces a dependency pass.
|
|
TRACKED_REQUIREMENT_FILES: Tuple[str, ...] = (
|
|
"studio.txt",
|
|
"base.txt",
|
|
"extras.txt",
|
|
"extras-no-deps.txt",
|
|
"no-torch-runtime.txt",
|
|
"single-env/data-designer-deps.txt",
|
|
"single-env/data-designer.txt",
|
|
)
|
|
|
|
# The import chain studio/backend/run.py walks on startup.
|
|
BOOT_REQUIREMENT_FILE = "studio.txt"
|
|
|
|
|
|
def venv_root() -> Path:
|
|
"""Directory holding pyvenv.cfg for the interpreter running this code."""
|
|
return Path(sys.prefix)
|
|
|
|
|
|
def manifest_path(root: Optional[Path] = None) -> Path:
|
|
return (root or venv_root()) / MANIFEST_NAME
|
|
|
|
|
|
def requirements_root(script_dir: Optional[Path] = None) -> Path:
|
|
"""studio/backend/requirements/ next to this module (or a given studio/ dir)."""
|
|
return (script_dir or Path(__file__).resolve().parent) / "backend" / "requirements"
|
|
|
|
|
|
def _sha256(path: Path) -> Optional[str]:
|
|
try:
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def installed_requirements_root(root: Optional[Path] = None) -> Optional[Path]:
|
|
"""The requirements the venv's *installed* package ships, if it has them.
|
|
|
|
The digests must describe the files `verify_install` will later read, and
|
|
that is always the installed package's copy: at verify time this module is
|
|
imported out of the venv, so `requirements_root()` resolves there, and
|
|
unsloth_cli/_studio_deps.py looks in the same place for a foreign venv.
|
|
|
|
The installer is a different tree. A desktop bundle carries its own
|
|
`studio/install_python_stack.py`, and its requirements are whatever they were
|
|
when that bundle was cut -- so recording the installer's digests makes every
|
|
install stale the moment a tracked requirement file changes upstream. That is
|
|
not hypothetical: v0.1.800-beta (2026-08-14) installed unsloth 2026.8.18,
|
|
#9148 had pinned openai in extras.txt in between, and every fresh Linux and
|
|
macOS desktop install came up `studio_install_requirements_changed` and paid
|
|
an immediate repair pass before it would run.
|
|
"""
|
|
prefix = root or venv_root()
|
|
for pattern in ("lib/python*/site-packages", "Lib/site-packages"):
|
|
for site in sorted(prefix.glob(pattern)):
|
|
reqs = site / "studio" / "backend" / "requirements"
|
|
if reqs.is_dir():
|
|
return reqs
|
|
return None
|
|
|
|
|
|
def requirement_digests(req_root: Optional[Path] = None) -> Dict[str, str]:
|
|
"""sha256 of every tracked requirement file that exists."""
|
|
root = req_root or requirements_root()
|
|
digests: Dict[str, str] = {}
|
|
for name in TRACKED_REQUIREMENT_FILES:
|
|
digest = _sha256(root / name)
|
|
if digest is not None:
|
|
digests[name] = digest
|
|
return digests
|
|
|
|
|
|
def _canonical(name: str) -> str:
|
|
"""PEP 503 normalisation, so PyJWT / pyjwt / py_jwt compare equal."""
|
|
return re.sub(r"[-_.]+", "-", name).lower()
|
|
|
|
|
|
def _metadata_scan_paths() -> List[str]:
|
|
"""This interpreter's site-packages roots, excluding inherited sys.path entries.
|
|
|
|
Deduplicated by real path, not by string: purelib hardcodes `lib` while
|
|
platlib follows sys.platlibdir, so a lib64 build (Fedora, SuSE) names one
|
|
directory twice through venv's lib64 -> lib symlink. Scanning both would
|
|
report every package twice and turn a healthy venv into a conflict.
|
|
"""
|
|
import sysconfig
|
|
|
|
paths: List[str] = []
|
|
seen: set = set()
|
|
try:
|
|
configured = sysconfig.get_paths()
|
|
except Exception:
|
|
return paths
|
|
for key in ("purelib", "platlib"):
|
|
path = configured.get(key)
|
|
if not path or not os.path.isdir(path):
|
|
continue
|
|
try:
|
|
key_path = os.path.realpath(path)
|
|
except OSError:
|
|
key_path = path
|
|
if key_path in seen:
|
|
continue
|
|
seen.add(key_path)
|
|
paths.append(path)
|
|
return paths
|
|
|
|
|
|
def _installed_metadata_records(dist_name: str) -> List[Tuple[str, Optional[Path]]]:
|
|
"""Every matching metadata version and its directory, when available."""
|
|
from importlib.metadata import distributions
|
|
|
|
wanted = _canonical(dist_name)
|
|
paths = _metadata_scan_paths()
|
|
kwargs = {"path": paths} if paths else {}
|
|
found: List[Tuple[str, Optional[Path]]] = []
|
|
for dist in distributions(**kwargs):
|
|
path = getattr(dist, "_path", None)
|
|
try:
|
|
record_path = Path(os.fspath(path)) if path is not None else None
|
|
except (TypeError, ValueError):
|
|
record_path = None
|
|
try:
|
|
name = dist.metadata.get("Name")
|
|
if name:
|
|
if _canonical(name) == wanted:
|
|
found.append((dist.version or "", record_path))
|
|
continue
|
|
except Exception:
|
|
pass
|
|
# A nameless or unreadable matching record is itself a conflict. Wheel
|
|
# metadata directory names escape name separators as underscores, so
|
|
# splitting off the final version is unambiguous.
|
|
stem = record_path.name if record_path is not None else ""
|
|
path_name, separator, _version = stem.removesuffix(".dist-info").rpartition("-")
|
|
if stem.endswith(".dist-info") and separator and _canonical(path_name) == wanted:
|
|
found.append(("", record_path))
|
|
return sorted(found, key = lambda record: (record[0], os.fspath(record[1] or "")))
|
|
|
|
|
|
def installed_versions(dist_name: str) -> List[str]:
|
|
"""Every metadata version for one canonical distribution name.
|
|
|
|
More than one answer is an inconsistent environment, not a choice between
|
|
equivalent records: importlib.metadata.version() returns whichever record
|
|
the finder yields first, which can be a dist-info left by a failed uninstall.
|
|
"""
|
|
return [version for version, _path in _installed_metadata_records(dist_name)]
|
|
|
|
|
|
def invalid_metadata_paths(dist_name: str) -> List[Path]:
|
|
"""Matching metadata directories that pip cannot safely identify."""
|
|
return [
|
|
path
|
|
for version, path in _installed_metadata_records(dist_name)
|
|
if not version and path is not None
|
|
]
|
|
|
|
|
|
def pip_backup_metadata_paths(dist_name: str) -> List[Path]:
|
|
"""Matching records left behind by an interrupted pip upgrade.
|
|
|
|
pip renames the outgoing distribution to a `~` prefixed sibling while it
|
|
installs the replacement, so a kill mid-operation keeps both. The METADATA
|
|
still names the real project, so it counts as a duplicate here, but pip
|
|
calls the directory invalid: `pip uninstall <name>` can never consume it.
|
|
"""
|
|
return [
|
|
path
|
|
for _version, path in _installed_metadata_records(dist_name)
|
|
if path is not None and path.name.startswith("~")
|
|
]
|
|
|
|
|
|
def metadata_conflict(versions: Sequence[str]) -> bool:
|
|
"""Whether matching metadata records are duplicated or unreadable."""
|
|
return len(versions) > 1 or any(not version for version in versions)
|
|
|
|
|
|
def _metadata_is_inconsistent(dist_name: str, versions: Optional[List[str]] = None) -> bool:
|
|
"""Duplicated, unreadable, or standing on a record pip will not honour.
|
|
|
|
A sole `~` backup is the case a version count cannot see: one readable
|
|
version, so nothing looks wrong, while pip refuses the directory and the
|
|
package tree is usually renamed away with it. Left unflagged, the fast path
|
|
calls the package up to date and skips the pass that would reinstall it.
|
|
"""
|
|
if versions is None:
|
|
versions = installed_versions(dist_name)
|
|
return bool(metadata_conflict(versions) or pip_backup_metadata_paths(dist_name))
|
|
|
|
|
|
def installed_version_probe(
|
|
dist_name: str, companion_names: Sequence[str] = ()
|
|
) -> Tuple[str, bool]:
|
|
"""One unambiguous version and whether any requested metadata conflicts."""
|
|
versions = installed_versions(dist_name)
|
|
conflict = _metadata_is_inconsistent(dist_name, versions) or any(
|
|
_metadata_is_inconsistent(name) for name in companion_names
|
|
)
|
|
version = versions[0] if len(versions) == 1 and versions[0] else ""
|
|
return version, conflict
|
|
|
|
|
|
def _installed_version(dist_name: str, installed: Optional[Dict[str, str]] = None) -> Optional[str]:
|
|
if installed is not None:
|
|
return installed.get(_canonical(dist_name))
|
|
return installed_version_probe(dist_name)[0] or None
|
|
|
|
|
|
def remove_manifest(root: Optional[Path] = None) -> bool:
|
|
"""Called before the dependency pass so an aborted run cannot leave a valid one.
|
|
|
|
True when no manifest remains. A surviving marker (Windows raises on a
|
|
read-only or locked file) still names this version and these digests, so a
|
|
pass killed afterwards would verify as complete.
|
|
"""
|
|
try:
|
|
manifest_path(root).unlink()
|
|
except FileNotFoundError:
|
|
return True
|
|
except OSError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def write_manifest(
|
|
root: Optional[Path] = None,
|
|
req_root: Optional[Path] = None,
|
|
steps_total: int = 0,
|
|
package_name: str = "unsloth",
|
|
no_torch: Optional[bool] = None,
|
|
) -> Optional[Path]:
|
|
"""Record a completed install. Never raises: no manifest reads as incomplete,
|
|
which is the safe answer."""
|
|
payload = {
|
|
"schema": MANIFEST_SCHEMA,
|
|
"completed_at_ms": int(time.time() * 1000),
|
|
"package": package_name,
|
|
"package_version": _installed_version(package_name),
|
|
"python": platform.python_version(),
|
|
"platform": f"{sys.platform}-{platform.machine()}",
|
|
"prefix": str(venv_root()),
|
|
"steps_total": steps_total,
|
|
# The venv's own copy wins over the caller's. `verify_install` reads the
|
|
# installed package's requirements, so recording the installer's would
|
|
# compare two different trees and call a finished install stale. An
|
|
# editable / source install has no copy under site-packages, and there
|
|
# the caller's root is already the tree both sides read.
|
|
"requirement_files": requirement_digests(installed_requirements_root(root) or req_root),
|
|
}
|
|
# Additive, so MANIFEST_SCHEMA does not move and every existing manifest stays
|
|
# valid. Absent means "unknown", which is NOT False: only a manifest written by
|
|
# a build that knew about the key can answer, and callers fall back to their own
|
|
# detection otherwise. Recorded because install.ps1 / install.sh export
|
|
# UNSLOTH_NO_TORCH for their own run only -- a later `unsloth studio update`
|
|
# exports nothing and would otherwise reinstall torch into a GGUF-only venv.
|
|
if no_torch is not None:
|
|
payload["no_torch"] = bool(no_torch)
|
|
path = manifest_path(root)
|
|
try:
|
|
tmp = path.with_suffix(".json.tmp")
|
|
tmp.write_text(json.dumps(payload, indent = 2, sort_keys = True), encoding = "utf-8")
|
|
os.replace(tmp, path)
|
|
return path
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def read_manifest(root: Optional[Path] = None) -> Optional[dict]:
|
|
try:
|
|
raw = manifest_path(root).read_text(encoding = "utf-8")
|
|
# UnicodeDecodeError is a ValueError, not an OSError: a manifest re-saved as
|
|
# ANSI by an editor (the payload embeds the user profile path, so non-ASCII
|
|
# names show up there) or truncated mid-write must read as "no manifest", not
|
|
# raise. install_python_stack.py resolves no-torch mode through here at import,
|
|
# so anything escaping aborts the whole install.
|
|
except (OSError, ValueError):
|
|
return None
|
|
try:
|
|
data = json.loads(raw)
|
|
except ValueError:
|
|
return None
|
|
return data if isinstance(data, dict) else None
|
|
|
|
|
|
def no_torch_marker_path(root: Optional[Path] = None) -> Path:
|
|
return (root or venv_root()) / NO_TORCH_MARKER
|
|
|
|
|
|
def set_no_torch_marker(no_torch: bool, root: Optional[Path] = None) -> None:
|
|
"""Record the mode outside the completion manifest. Never raises.
|
|
|
|
Written before the dependency pass so an interrupted install still knows what
|
|
it was building. Removed when torch is wanted, so migrating out of no-torch
|
|
does not leave a stale marker behind.
|
|
"""
|
|
path = no_torch_marker_path(root)
|
|
try:
|
|
if no_torch:
|
|
path.write_text("", encoding = "utf-8")
|
|
else:
|
|
path.unlink(missing_ok = True)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def recorded_no_torch(root: Optional[Path] = None) -> Optional[bool]:
|
|
"""The mode this venv was installed with, or None when unknown.
|
|
|
|
None means nothing recorded it: no manifest key and no marker. Callers must
|
|
fall back to their own detection on None and never to False, so an install
|
|
made before either existed is not silently switched out of no-torch mode.
|
|
"""
|
|
manifest = read_manifest(root)
|
|
if manifest is not None:
|
|
value = manifest.get("no_torch")
|
|
if isinstance(value, bool):
|
|
return value
|
|
# Tolerate a hand-edited manifest that used a string.
|
|
if isinstance(value, str):
|
|
return value.strip().lower() in NO_TORCH_TRUTHY
|
|
# No manifest (dropped before the dependency pass, or the install was killed
|
|
# during it) or one predating the key: the marker is the durable answer.
|
|
try:
|
|
if no_torch_marker_path(root).exists():
|
|
return True
|
|
except OSError:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _parse_requirement_line(line: str) -> Optional[Tuple[str, str, str]]:
|
|
"""(distribution name, marker, specifier) for a requirement, or None.
|
|
|
|
Covers what studio.txt uses: names, specifiers, inline comments, markers.
|
|
pip flags are skipped.
|
|
"""
|
|
text = line.split("#", 1)[0].strip()
|
|
if not text or text.startswith("-"):
|
|
return None
|
|
try:
|
|
from packaging.requirements import Requirement
|
|
requirement = Requirement(text)
|
|
return (
|
|
requirement.name,
|
|
str(requirement.marker or ""),
|
|
str(requirement.specifier),
|
|
)
|
|
except Exception:
|
|
pass
|
|
marker = ""
|
|
if ";" in text:
|
|
text, marker = text.split(";", 1)
|
|
marker = marker.strip()
|
|
name = text.strip()
|
|
for sep in ("===", "==", ">=", "<=", "~=", "!=", ">", "<", "[", " "):
|
|
idx = name.find(sep)
|
|
if idx > 0:
|
|
name = name[:idx]
|
|
name = name.strip()
|
|
return (name, marker, "") if name else None
|
|
|
|
|
|
def _marker_applies(marker: str) -> bool:
|
|
"""True when the environment marker matches (or cannot be evaluated)."""
|
|
if not marker:
|
|
return True
|
|
try:
|
|
from packaging.markers import Marker
|
|
except Exception:
|
|
# No packaging: assume it applies. Over-reporting costs one extra pass.
|
|
return True
|
|
try:
|
|
return bool(Marker(marker).evaluate())
|
|
except Exception:
|
|
return True
|
|
|
|
|
|
def _version_satisfies(version: str, specifier: str) -> bool:
|
|
if not specifier:
|
|
return True
|
|
try:
|
|
from packaging.specifiers import SpecifierSet
|
|
return SpecifierSet(specifier).contains(version)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def missing_requirements(
|
|
req_file: Optional[Path] = None, installed: Optional[Dict[str, str]] = None
|
|
) -> List[str]:
|
|
"""Distribution names that are missing or outside their required versions.
|
|
|
|
Checked via importlib.metadata, not import names, because studio.txt lists
|
|
PyJWT / python-docx / pymupdf whose import names (jwt, docx, fitz) differ.
|
|
|
|
`installed` (canonical distribution name -> version) checks a venv other
|
|
than the one running this code, which importlib.metadata cannot see.
|
|
"""
|
|
from importlib.metadata import PackageNotFoundError, distribution
|
|
|
|
path = req_file or (requirements_root() / BOOT_REQUIREMENT_FILE)
|
|
try:
|
|
lines = path.read_text(encoding = "utf-8").splitlines()
|
|
except OSError:
|
|
return []
|
|
|
|
missing: List[str] = []
|
|
for line in lines:
|
|
parsed = _parse_requirement_line(line)
|
|
if parsed is None:
|
|
continue
|
|
name, marker, specifier = parsed
|
|
if not _marker_applies(marker):
|
|
continue
|
|
if installed is not None:
|
|
version = installed.get(_canonical(name))
|
|
if version is None or not _version_satisfies(version, specifier):
|
|
missing.append(name)
|
|
continue
|
|
try:
|
|
dist = distribution(name)
|
|
except PackageNotFoundError:
|
|
missing.append(name)
|
|
except Exception:
|
|
missing.append(name)
|
|
else:
|
|
if not _version_satisfies(dist.version, specifier):
|
|
missing.append(name)
|
|
return missing
|
|
|
|
|
|
def verify_install(
|
|
root: Optional[Path] = None,
|
|
req_root: Optional[Path] = None,
|
|
package_name: str = "unsloth",
|
|
installed: Optional[Dict[str, str]] = None,
|
|
installed_conflicts: Optional[Sequence[str]] = None,
|
|
) -> dict:
|
|
"""Report whether the managed install finished and can still boot.
|
|
|
|
Reason strings are surfaced verbatim by the desktop preflight as its
|
|
staleness reason, so keep them stable.
|
|
|
|
Pass `installed`, `installed_conflicts`, and the matching `root` / `req_root`
|
|
to describe a venv other than this interpreter's; without them the version
|
|
and dependency checks would answer for the venv the caller happens to be
|
|
running in.
|
|
"""
|
|
reqs = req_root or requirements_root()
|
|
missing = missing_requirements(reqs / BOOT_REQUIREMENT_FILE, installed = installed)
|
|
deps_ok = not missing
|
|
|
|
manifest = read_manifest(root)
|
|
manifest_ok = False
|
|
reason: Optional[str] = None
|
|
|
|
if manifest is None:
|
|
reason = "studio_install_incomplete"
|
|
elif manifest.get("schema") != MANIFEST_SCHEMA:
|
|
reason = "studio_install_manifest_schema"
|
|
else:
|
|
# `update --package X` records X, so comparing against unsloth would
|
|
# report a permanent version change.
|
|
manifest_package = manifest.get("package") or package_name
|
|
if installed is None:
|
|
companions = () if _canonical(manifest_package) == "unsloth-zoo" else ("unsloth-zoo",)
|
|
current, local_conflict = installed_version_probe(manifest_package, companions)
|
|
else:
|
|
current = _installed_version(manifest_package, installed)
|
|
local_conflict = False
|
|
foreign_conflicts = {_canonical(name) for name in (installed_conflicts or ())}
|
|
core_conflict = _canonical(manifest_package) in foreign_conflicts or (
|
|
_canonical(manifest_package) != "unsloth-zoo" and "unsloth-zoo" in foreign_conflicts
|
|
)
|
|
recorded = manifest.get("package_version")
|
|
if core_conflict or local_conflict:
|
|
reason = "studio_install_metadata_conflict"
|
|
elif current and recorded and current != recorded:
|
|
reason = "studio_install_version_changed"
|
|
elif manifest.get("requirement_files") != requirement_digests(reqs):
|
|
reason = "studio_install_requirements_changed"
|
|
else:
|
|
manifest_ok = True
|
|
|
|
if manifest_ok and not deps_ok:
|
|
# Install finished but the boot deps are gone: venv edited afterwards.
|
|
reason = "studio_deps_missing"
|
|
|
|
return {
|
|
"ok": manifest_ok and deps_ok,
|
|
"manifest_ok": manifest_ok,
|
|
"deps_ok": deps_ok,
|
|
"missing": missing,
|
|
"reason": None if (manifest_ok and deps_ok) else (reason or "studio_deps_missing"),
|
|
}
|