diff --git a/pyproject.toml b/pyproject.toml index d0b0845b93..3bdb64f566 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/tests/python/test_pyproject_pypi_installable.py b/tests/python/test_pyproject_pypi_installable.py new file mode 100644 index 0000000000..916aa2ce29 --- /dev/null +++ b/tests/python/test_pyproject_pypi_installable.py @@ -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 = [("", 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}" diff --git a/tests/python/test_torchcodec_torch_compat.py b/tests/python/test_torchcodec_torch_compat.py index 6ad16a73f4..b90f4ccba8 100644 --- a/tests/python/test_torchcodec_torch_compat.py +++ b/tests/python/test_torchcodec_torch_compat.py @@ -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():