pip: add the amd and huggingfacenotorch extras, and guard against unpublishable metadata (#7583)
* pip: add the amd and huggingfacenotorch extras, and guard against unpublishable metadata * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * pip: restore the rich, audio and flash-attn dependencies main declares * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Parse the guard tests' requirements instead of pattern matching them The direct-URL check matched the substring " @ https://", but PEP 508 allows any whitespace around the @ and the scheme is case insensitive, so flash-attn@https://... and flash-attn @ HTTPS://... are both direct references the check waved through - the exact upload failure it exists to catch. Ask packaging: Requirement.url is set for every spelling. The extras cross-reference used a lowercase regex, but project names and extra names are both case and separator insensitive (PEP 503, PEP 685), so pip honours Unsloth[Rocm72_Torch2100] while the regex never looked at it. Parse and canonicalize both sides instead. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * pip: gate the torchcodec extras to platforms with a wheel torchcodec publishes no sdist and only manylinux_2_28_x86_64, macosx_*_arm64 and win_amd64 wheels, so on Linux aarch64, Windows ARM64 and Intel Mac there is nothing for pip to resolve and the whole install fails before the user gets an environment. Wiring audio-torch210 into the cu*-torch2100 extras made that reachable from those extras too. Gate on the platforms that have a wheel, matching PLATFORM_LACKS_TORCHCODEC_WHEEL in studio/install_python_stack.py. Also record why the amd and huggingfacenotorch extras cannot be torch-free on this branch: extras are additive to the base dependencies, which deliberately carry the full runtime here so a bare pip install works. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: unslothai <unslothai@gmail.com>
This commit is contained in:
parent
0d320c5b56
commit
e3052ed7d2
3 changed files with 248 additions and 2 deletions
|
|
@ -59,6 +59,10 @@ dependencies = [
|
|||
# imports that, so every command needs it. typer supplied it until 0.27
|
||||
# dropped the dependency, which left this satisfied only by chance.
|
||||
"click>=8.0",
|
||||
# unsloth_cli/__init__.py imports commands/chat.py, which imports rich at
|
||||
# module level, so every command needs it. typer still pulls rich, but it
|
||||
# pulled click too until 0.26.0 dropped it: same shape, so declare it.
|
||||
"rich",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
@ -153,6 +157,47 @@ huggingface = [
|
|||
"pyyaml",
|
||||
"nest-asyncio",
|
||||
]
|
||||
# "notorch" means this list declares no torch of its own, not that installing it
|
||||
# leaves you torch-free: extras are additive to [project].dependencies, which on
|
||||
# this branch deliberately carry the full runtime so a bare `pip install unsloth`
|
||||
# works, and accelerate/peft/sentence-transformers each require torch anyway. The
|
||||
# genuinely torch-free path is the installer's --no-deps pass over
|
||||
# studio/backend/requirements/no-torch-runtime.txt, not plain pip resolution.
|
||||
# Kept byte-identical to main so the two branches can be diffed for drift.
|
||||
huggingfacenotorch = [
|
||||
"unsloth_zoo>=2026.7.6",
|
||||
"wheel>=0.42.0",
|
||||
"packaging",
|
||||
"numpy",
|
||||
"tqdm",
|
||||
"psutil",
|
||||
"tyro",
|
||||
"protobuf",
|
||||
"sentencepiece>=0.2.0",
|
||||
"datasets>=3.4.1,!=4.0.*,!=4.1.0,<4.4.0",
|
||||
"accelerate>=0.34.1",
|
||||
"peft>=0.18.0,!=0.11.0",
|
||||
"huggingface_hub>=0.34.0",
|
||||
"hf_transfer",
|
||||
"diffusers",
|
||||
"transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0",
|
||||
"trl>=0.18.2,!=0.19.0,<=0.24.0",
|
||||
"sentence-transformers",
|
||||
]
|
||||
# What this extra does on this branch is raise the bitsandbytes floor; it does not
|
||||
# hand you a ROCm stack. The base dependencies still apply, so ROCm torch has to
|
||||
# come from the AMD index or already be installed (pip leaves an installed
|
||||
# +rocm torch alone, since it satisfies the base specifier). Use install.sh or
|
||||
# `unsloth studio install` for a torch-free AMD install.
|
||||
amd = [
|
||||
"unsloth[huggingfacenotorch]",
|
||||
# 4-bit decode is unreliable on ROCm before 0.50.0, the first PyPI release
|
||||
# carrying the full path: blocksize/warp decoupling (bnb #1887), fused SIMT
|
||||
# GEMM on RDNA (#1979), RDNA3/4 workgroup fix (#2012). Keep in step with
|
||||
# install.sh and studio/install_python_stack.py.
|
||||
"bitsandbytes>=0.50.0 ; ('linux' in sys_platform) and (platform_machine == 'AMD64' or platform_machine == 'x86_64' or platform_machine == 'aarch64')",
|
||||
"bitsandbytes>=0.50.0 ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
|
||||
]
|
||||
windows = [
|
||||
"unsloth[huggingface]",
|
||||
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0 ; (sys_platform == 'win32')",
|
||||
|
|
@ -477,16 +522,19 @@ cu126-torch2100 = [
|
|||
"unsloth[huggingface]",
|
||||
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
|
||||
"unsloth[cu126onlytorch2100]",
|
||||
"unsloth[audio-torch210]",
|
||||
]
|
||||
cu128-torch2100 = [
|
||||
"unsloth[huggingface]",
|
||||
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
|
||||
"unsloth[cu128onlytorch2100]",
|
||||
"unsloth[audio-torch210]",
|
||||
]
|
||||
cu130-torch2100 = [
|
||||
"unsloth[huggingface]",
|
||||
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
|
||||
"unsloth[cu130onlytorch2100]",
|
||||
"unsloth[audio-torch210]",
|
||||
]
|
||||
kaggle = [
|
||||
"unsloth[huggingface]",
|
||||
|
|
@ -508,6 +556,21 @@ flashattention = [
|
|||
"ninja ; ('linux' in sys_platform)",
|
||||
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
|
||||
]
|
||||
# torchcodec is the audio decode path; its releases track torch minor versions,
|
||||
# so each torch minor gets its own window (#7225). It publishes no sdist and only
|
||||
# manylinux_2_28_x86_64, macosx_*_arm64 and win_amd64 wheels, so Linux aarch64,
|
||||
# Windows ARM64 and Intel Mac have nothing to resolve and pip would hard-fail the
|
||||
# whole install. Gate on the platforms that have a wheel, matching
|
||||
# PLATFORM_LACKS_TORCHCODEC_WHEEL in studio/install_python_stack.py.
|
||||
audio-torch280 = [
|
||||
"torchcodec>=0.6.0,<0.8.0 ; python_version >= '3.9' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))",
|
||||
]
|
||||
audio-torch290 = [
|
||||
"torchcodec>=0.8.0,<0.10.0 ; python_version >= '3.10' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))",
|
||||
]
|
||||
audio-torch210 = [
|
||||
"torchcodec>=0.10.0,<0.11.0 ; python_version >= '3.10' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))",
|
||||
]
|
||||
colab-ampere-torch211 = [
|
||||
"unsloth[huggingface]",
|
||||
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
|
||||
|
|
@ -740,6 +803,7 @@ cu130-ampere-torch280 = [
|
|||
"unsloth[huggingface]",
|
||||
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
|
||||
"unsloth[cu130onlytorch280]",
|
||||
"unsloth[flashattention]",
|
||||
]
|
||||
cu126-ampere-torch290 = [
|
||||
"unsloth[huggingface]",
|
||||
|
|
@ -775,16 +839,19 @@ cu126-ampere-torch2100 = [
|
|||
"unsloth[huggingface]",
|
||||
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
|
||||
"unsloth[cu126onlytorch2100]",
|
||||
"unsloth[audio-torch210]",
|
||||
]
|
||||
cu128-ampere-torch2100 = [
|
||||
"unsloth[huggingface]",
|
||||
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
|
||||
"unsloth[cu128onlytorch2100]",
|
||||
"unsloth[audio-torch210]",
|
||||
]
|
||||
cu130-ampere-torch2100 = [
|
||||
"unsloth[huggingface]",
|
||||
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
|
||||
"unsloth[cu130onlytorch2100]",
|
||||
"unsloth[audio-torch210]",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
|
|
|||
172
tests/python/test_pyproject_pypi_installable.py
Normal file
172
tests/python/test_pyproject_pypi_installable.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""This branch is what gets uploaded to PyPI, so its metadata has to satisfy PyPI's rules.
|
||||
|
||||
PyPI rejects any PEP 508 direct reference in ``Requires-Dist`` with
|
||||
``Invalid value for requires_dist. Error: Can't have direct dependency``
|
||||
(pypi/warehouse#7136), and ``twine check`` does not catch it beforehand
|
||||
(pypa/twine#726) -- the upload just 400s. ``main`` carries hundreds of direct URL
|
||||
requirements for the CUDA/XPU/ROCm wheel indexes, so every merge from ``main`` is a
|
||||
chance to import one here and break publishing. Nothing else guards that.
|
||||
|
||||
Offline by design: structural checks only, no network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else:
|
||||
tomllib = pytest.importorskip("tomli")
|
||||
return tomllib.loads(PYPROJECT.read_text(encoding = "utf-8"))
|
||||
|
||||
|
||||
def _all_requirements() -> list[tuple[str, str]]:
|
||||
"""(extra name, requirement string) for the base deps and every extra."""
|
||||
project = _load()["project"]
|
||||
items = [("<base>", r) for r in project.get("dependencies", [])]
|
||||
for extra, deps in project.get("optional-dependencies", {}).items():
|
||||
items += [(extra, r) for r in deps]
|
||||
return items
|
||||
|
||||
|
||||
class TestNoDirectUrlRequirements:
|
||||
def test_no_requirement_uses_a_direct_url(self):
|
||||
"""Ask packaging, not the spelling.
|
||||
|
||||
PEP 508 allows any whitespace around the `@`, and the scheme is case
|
||||
insensitive, so `flash-attn@https://...` and `flash-attn @ HTTPS://...` are both
|
||||
valid direct references that a substring match on " @ https://" would wave
|
||||
through - the exact upload failure this guards. `Requirement.url` is set for
|
||||
every form of them.
|
||||
"""
|
||||
packaging_requirements = pytest.importorskip("packaging.requirements")
|
||||
offenders = []
|
||||
for extra, req in _all_requirements():
|
||||
try:
|
||||
parsed = packaging_requirements.Requirement(req)
|
||||
except Exception: # noqa: BLE001 - test_every_requirement_parses reports it
|
||||
continue
|
||||
if parsed.url:
|
||||
offenders.append((extra, req))
|
||||
assert offenders == [], (
|
||||
"PyPI rejects direct references in Requires-Dist, so these would fail the "
|
||||
f"upload (only found at publish time): {offenders[:5]}"
|
||||
)
|
||||
|
||||
def test_every_requirement_parses(self):
|
||||
packaging_requirements = pytest.importorskip("packaging.requirements")
|
||||
bad = []
|
||||
for extra, req in _all_requirements():
|
||||
try:
|
||||
packaging_requirements.Requirement(req)
|
||||
except Exception as exc: # noqa: BLE001 - report every malformed spec at once
|
||||
bad.append((extra, req, str(exc)))
|
||||
assert bad == [], f"unparseable requirements: {bad}"
|
||||
|
||||
|
||||
class TestExtraReferencesResolve:
|
||||
def test_every_unsloth_extra_reference_exists(self):
|
||||
"""A `unsloth[foo]` pointing at an extra this branch does not define installs
|
||||
nothing and fails silently, which is how a partially ported extras block breaks.
|
||||
|
||||
Parsed and canonicalized rather than pattern-matched: project names and extra
|
||||
names are both case and separator insensitive (PEP 503, PEP 685), so pip honours
|
||||
`Unsloth[Rocm72_Torch2100]` while a lowercase regex would never look at it.
|
||||
"""
|
||||
packaging_requirements = pytest.importorskip("packaging.requirements")
|
||||
packaging_utils = pytest.importorskip("packaging.utils")
|
||||
canonicalize = packaging_utils.canonicalize_name
|
||||
|
||||
extras = _load()["project"].get("optional-dependencies", {})
|
||||
defined = {canonicalize(name) for name in extras}
|
||||
dangling = []
|
||||
for name, deps in extras.items():
|
||||
for dep in deps:
|
||||
try:
|
||||
parsed = packaging_requirements.Requirement(dep)
|
||||
except Exception: # noqa: BLE001 - test_every_requirement_parses reports it
|
||||
continue
|
||||
if canonicalize(parsed.name) != "unsloth":
|
||||
continue
|
||||
for ref in parsed.extras:
|
||||
if canonicalize(ref) not in defined:
|
||||
dangling.append((name, ref))
|
||||
assert dangling == [], f"extras referencing undefined extras: {dangling}"
|
||||
|
||||
|
||||
class TestAmdExtraIsInstallableFromPyPI:
|
||||
"""`pip install unsloth[amd]` is the supported AMD entry point, so the extra has to
|
||||
exist here and stay a version floor -- a URL pin would be unpublishable."""
|
||||
|
||||
def test_amd_extra_exists_and_floors_bitsandbytes(self):
|
||||
extras = _load()["project"].get("optional-dependencies", {})
|
||||
assert "amd" in extras, "pyproject.toml must define an `amd` extra"
|
||||
specs = [d for d in extras["amd"] if d.lower().startswith("bitsandbytes")]
|
||||
assert specs, "the amd extra must constrain bitsandbytes"
|
||||
for spec in specs:
|
||||
assert spec.startswith(
|
||||
"bitsandbytes>=0.50.0"
|
||||
), f"bitsandbytes <= 0.49.2 NaNs at 4-bit decode on ROCm; got {spec!r}"
|
||||
|
||||
def test_amd_extra_pulls_the_torch_free_runtime(self):
|
||||
extras = _load()["project"].get("optional-dependencies", {})
|
||||
assert any("huggingfacenotorch" in d for d in extras["amd"])
|
||||
assert "huggingfacenotorch" in extras
|
||||
|
||||
|
||||
class TestRuntimeImportsAreDeclared:
|
||||
"""This branch's base install has to satisfy every module-scope import on the CLI
|
||||
entry path. typer supplied click until 0.26.0 dropped it (#7504); it still supplies
|
||||
rich, so rich is satisfied only by chance unless we declare it ourselves."""
|
||||
|
||||
ENTRY_PATH_IMPORTS = ("click", "rich", "structlog", "typer")
|
||||
|
||||
def test_cli_entry_path_imports_are_base_dependencies(self):
|
||||
packaging_requirements = pytest.importorskip("packaging.requirements")
|
||||
base = _load()["project"].get("dependencies", [])
|
||||
declared = {
|
||||
packaging_requirements.Requirement(r).name.lower().replace("_", "-") for r in base
|
||||
}
|
||||
missing = [p for p in self.ENTRY_PATH_IMPORTS if p not in declared]
|
||||
assert missing == [], (
|
||||
"imported at module scope by unsloth_cli/__init__.py's import chain but not "
|
||||
f"declared in base dependencies: {missing}"
|
||||
)
|
||||
|
||||
|
||||
class TestAcceleratorExtrasCarryTheirCompanions:
|
||||
"""Each accelerator extra composes a stack: `-ampere-` variants add flash-attn and
|
||||
torch 2.10 variants add the torchcodec audio path. A variant that silently drops one
|
||||
installs a quietly weaker environment than its siblings."""
|
||||
|
||||
def test_torch2100_extras_pull_the_audio_path(self):
|
||||
extras = _load()["project"].get("optional-dependencies", {})
|
||||
targets = [
|
||||
n for n in extras if n.endswith("torch2100") and "only" not in n and n.startswith("cu")
|
||||
]
|
||||
assert targets, "expected cu*-torch2100 extras to exist"
|
||||
missing = [n for n in targets if not any("audio-torch" in d for d in extras[n])]
|
||||
assert missing == [], f"torch 2.10 extras missing the audio extra: {missing}"
|
||||
|
||||
def test_ampere_torch280_extras_pull_flash_attention(self):
|
||||
extras = _load()["project"].get("optional-dependencies", {})
|
||||
targets = [n for n in extras if n.endswith("-ampere-torch280")]
|
||||
assert targets, "expected *-ampere-torch280 extras to exist"
|
||||
missing = [
|
||||
n
|
||||
for n in targets
|
||||
if not any("flashattention" in d or "flash-attn" in d for d in extras[n])
|
||||
]
|
||||
assert missing == [], f"ampere torch 2.8 extras missing flash-attn: {missing}"
|
||||
|
|
@ -45,15 +45,22 @@ def _stub_torch(monkeypatch, version: str):
|
|||
|
||||
|
||||
def test_torch210_extras_bundle_audio_torch210():
|
||||
"""The ROCm extras pin torch from the AMD wheel index, which PyPI rejects as a
|
||||
direct reference, so this branch does not carry them. Check whichever torch 2.10
|
||||
extras it does define, and require at least one."""
|
||||
text = PYPROJECT.read_text(encoding = "utf-8")
|
||||
checked = 0
|
||||
for extra in (
|
||||
"cu128-torch2100",
|
||||
"cu126-ampere-torch2100",
|
||||
"rocm72-torch2100",
|
||||
):
|
||||
match = re.search(rf"^{extra} = \[(.*?)^\]", text, re.MULTILINE | re.DOTALL)
|
||||
assert match is not None, extra
|
||||
assert "unsloth[audio-torch210]" in match.group(1)
|
||||
if match is None:
|
||||
continue
|
||||
assert "unsloth[audio-torch210]" in match.group(1), extra
|
||||
checked += 1
|
||||
assert checked, "no torch 2.10 extra found to check"
|
||||
|
||||
|
||||
def test_torchcodec_matrix_matches_notebook_validator():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue