unsloth/tests/python/test_pyproject_pypi_installable.py

152 lines
6.4 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
"""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 re
import sys
import pytest
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
PYPROJECT = REPO_ROOT / "pyproject.toml"
_EXTRA_REF = re.compile(r"unsloth\[([\w\-\.,\s]+)\]")
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):
offenders = [
(extra, req)
for extra, req in _all_requirements()
if "@ http://" in req or "@ https://" in req or "@ git+" in 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."""
extras = _load()["project"].get("optional-dependencies", {})
dangling = []
for name, deps in extras.items():
for dep in deps:
for match in _EXTRA_REF.finditer(dep):
for ref in match.group(1).split(","):
ref = ref.strip()
if ref and ref not in extras:
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}"