Compare commits
104 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3052ed7d2 |
||
|
|
0d320c5b56 | ||
|
|
0756f8f645 | ||
|
|
2bea7862ef |
||
|
|
735fcde44c |
||
|
|
8d38165771 | ||
|
|
3455b45977 | ||
|
|
b1b499c743 | ||
|
|
b22b243e1b | ||
|
|
ef4894c559 | ||
|
|
6c3c01c0c9 | ||
|
|
5dec1284eb | ||
|
|
7beecd279e | ||
|
|
75f831616e | ||
|
|
4373fdbaee | ||
|
|
3f55e7a98c | ||
|
|
a9b3321915 | ||
|
|
a42720636b | ||
|
|
2f111da0f6 | ||
|
|
2c9587d9fc | ||
|
|
c436f3ca67 | ||
|
|
12f34025dc | ||
|
|
5e5c6c8fed | ||
|
|
287243a8fd | ||
|
|
4aa7dd5b18 | ||
|
|
c9aa094c91 | ||
|
|
1b1807ef9a | ||
|
|
a370521879 | ||
|
|
b712f9f557 | ||
|
|
667467a315 | ||
|
|
55075f691e | ||
|
|
0e929cddac | ||
|
|
ecaf3dde2a | ||
|
|
fc9d82f6bf | ||
|
|
4d2afc62a0 | ||
|
|
43c669e6ec | ||
|
|
11b69c1051 | ||
|
|
39c7a4d290 | ||
|
|
ff1088af40 | ||
|
|
6d3849b821 | ||
|
|
8e26a368e1 | ||
|
|
e31d4c6aea | ||
|
|
5688072af6 | ||
|
|
d3ac7447eb | ||
|
|
2fdfe24fb1 | ||
|
|
e2215c9d11 | ||
|
|
b11f13a710 | ||
|
|
66ae2d416c | ||
|
|
b0a35ddeab | ||
|
|
405add94a1 | ||
|
|
64dc11faa2 | ||
|
|
07e2fccf38 | ||
|
|
d482382a92 |
||
|
|
342bbc2333 | ||
|
|
e1fe3be939 | ||
|
|
d459f60458 | ||
|
|
2c4bf9ae35 | ||
|
|
a87a08610e | ||
|
|
41abe89041 | ||
|
|
d638d1bd6f | ||
|
|
2e3b2bdc27 | ||
|
|
9cc539c1b4 | ||
|
|
973c7d80c2 | ||
|
|
c27f9b99e9 | ||
|
|
0c24d61708 | ||
|
|
e9a2b5c010 | ||
|
|
e8355451ea | ||
|
|
a7b4ae19ee | ||
|
|
9066946615 | ||
|
|
b2580ae32b | ||
|
|
7ae9580ce6 | ||
|
|
9ad3b761ee | ||
|
|
8f721d28d6 | ||
|
|
396aa05ead | ||
|
|
2297f73cad | ||
|
|
a728f7c308 | ||
|
|
884152daee | ||
|
|
7437af2e44 | ||
|
|
67678d2c29 | ||
|
|
e568000a92 | ||
|
|
6700dd60f0 | ||
|
|
abf578327c | ||
|
|
a4ae80cd6a | ||
|
|
ec47b2984d | ||
|
|
d9d1a63397 | ||
|
|
0bb6379aad | ||
|
|
1c608e8ff7 | ||
|
|
f9adf6834d | ||
|
|
1fd5853741 | ||
|
|
93a70fbe4e | ||
|
|
8b77451e75 | ||
|
|
481f0618ff | ||
|
|
ddf6f6d1f9 | ||
|
|
dddc9eac92 | ||
|
|
da81c94510 | ||
|
|
4c139503c1 | ||
|
|
9d1e3c38bc | ||
|
|
8bc26f4e1d | ||
|
|
044e67f5aa | ||
|
|
ba36c12240 | ||
|
|
0a5652281f | ||
|
|
bf63f79414 | ||
|
|
2973bea3d0 | ||
|
|
ddbbfe52cf |
4 changed files with 344 additions and 661 deletions
813
pyproject.toml
813
pyproject.toml
File diff suppressed because it is too large
Load diff
|
|
@ -874,10 +874,15 @@ class TestAmdBnbFloorParity:
|
||||||
FLOOR = "0.50.0"
|
FLOOR = "0.50.0"
|
||||||
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
||||||
|
|
||||||
def test_amd_extra_floor(self):
|
def test_amd_extra_floor_when_present(self):
|
||||||
|
"""This branch ships no `amd` extra: main's version carries direct URL
|
||||||
|
requirements, which PyPI rejects in Requires-Dist, so the extras block is not
|
||||||
|
merged here. Assert the floor once #7278 adds the extra in floor form, so it
|
||||||
|
cannot drift from the two installer fallbacks."""
|
||||||
text = self.PYPROJECT.read_text(encoding = "utf-8")
|
text = self.PYPROJECT.read_text(encoding = "utf-8")
|
||||||
amd = re.search(r"^amd = \[(.*?)^\]", text, re.S | re.M)
|
amd = re.search(r"^amd = \[(.*?)^\]", text, re.S | re.M)
|
||||||
assert amd, "pyproject.toml must define an `amd` extra"
|
if amd is None:
|
||||||
|
pytest.skip("pyproject.toml has no amd extra on this branch")
|
||||||
specs = re.findall(r'"(bitsandbytes[^"]*)"', amd.group(1))
|
specs = re.findall(r'"(bitsandbytes[^"]*)"', amd.group(1))
|
||||||
assert specs, "the amd extra must pin bitsandbytes"
|
assert specs, "the amd extra must pin bitsandbytes"
|
||||||
for spec in specs:
|
for spec in specs:
|
||||||
|
|
|
||||||
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():
|
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")
|
text = PYPROJECT.read_text(encoding = "utf-8")
|
||||||
|
checked = 0
|
||||||
for extra in (
|
for extra in (
|
||||||
"cu128-torch2100",
|
"cu128-torch2100",
|
||||||
"cu126-ampere-torch2100",
|
"cu126-ampere-torch2100",
|
||||||
"rocm72-torch2100",
|
"rocm72-torch2100",
|
||||||
):
|
):
|
||||||
match = re.search(rf"^{extra} = \[(.*?)^\]", text, re.MULTILINE | re.DOTALL)
|
match = re.search(rf"^{extra} = \[(.*?)^\]", text, re.MULTILINE | re.DOTALL)
|
||||||
assert match is not None, extra
|
if match is None:
|
||||||
assert "unsloth[audio-torch210]" in match.group(1)
|
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():
|
def test_torchcodec_matrix_matches_notebook_validator():
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue