diff --git a/pyproject.toml b/pyproject.toml index fe0ebd13b9..0f57ecf4df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,6 +93,17 @@ huggingfacenotorch = [ "trl>=0.18.2,!=0.19.0,<=0.24.0", "sentence-transformers", ] +# torchcodec backend for Gemma audio / datasets>=4 (#7225). +# Pick the audio-torch* pin matching your torch minor (see TORCH_TORCHCODEC). +audio-torch210 = [ + "torchcodec>=0.10.0,<0.11.0 ; python_version >= '3.10'", +] +audio-torch290 = [ + "torchcodec>=0.8.0,<0.10.0 ; python_version >= '3.10'", +] +audio-torch280 = [ + "torchcodec>=0.6.0,<0.8.0 ; python_version >= '3.9'", +] huggingface = [ "unsloth[huggingfacenotorch]", "unsloth_zoo>=2026.7.6", @@ -532,16 +543,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]", @@ -831,16 +845,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]", ] flashattentiontorch260abiFALSEcu12x = [ "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.6cxx11abiFALSE-cp39-cp39-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.9'", @@ -1125,7 +1142,8 @@ intelgputorch210 = [ "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.25.0%2Bxpu-cp313-cp313-win_amd64.whl#sha256=1c4b44b36a557f7381e3076fb8843366742238648441d607c8d049c6da0f8886 ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", ] intel-gpu-torch210 = [ - "unsloth[intelgputorch210]" + "unsloth[intelgputorch210]", + "unsloth[audio-torch210]", ] intelgputorch2110 = [ "unsloth_zoo[intelgpu]", @@ -1279,6 +1297,7 @@ rocm72-torch2100 = [ "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/torchvision-0.25.0%2Brocm7.2.0.git82df5f59-cp311-cp311-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/torchvision-0.25.0%2Brocm7.2.0.git82df5f59-cp312-cp312-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/torchvision-0.25.0%2Brocm7.2.0.git82df5f59-cp313-cp313-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "unsloth[audio-torch210]", ] rocm711-torch2100 = [ "unsloth[amd]", @@ -1297,6 +1316,7 @@ rocm711-torch2100 = [ "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.1.1/torchvision-0.25.0%2Brocm7.1.1.git82df5f59-cp311-cp311-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.1.1/torchvision-0.25.0%2Brocm7.1.1.git82df5f59-cp312-cp312-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.1.1/torchvision-0.25.0%2Brocm7.1.1.git82df5f59-cp313-cp313-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "unsloth[audio-torch210]", ] [project.urls] diff --git a/scripts/notebook_validator.py b/scripts/notebook_validator.py index c1be7a63a4..7bcee47c66 100644 --- a/scripts/notebook_validator.py +++ b/scripts/notebook_validator.py @@ -95,8 +95,8 @@ COLAB_ORACLE_BASE_URL = "https://raw.githubusercontent.com/googlecolab/backend-i # Source: pytorch/torchcodec compatibility matrix on its README. TORCH_TORCHCODEC: dict[str, set[str]] = { "2.10": {"0.10"}, - "2.9": {"0.7", "0.8", "0.9"}, - "2.8": {"0.6"}, + "2.9": {"0.8", "0.9"}, + "2.8": {"0.6", "0.7"}, "2.7": {"0.3", "0.4", "0.5"}, "2.6": {"0.2", "0.3"}, "2.5": {"0.1", "0.2"}, diff --git a/scripts/scan_packages_baseline.json b/scripts/scan_packages_baseline.json index 65b8d2b11c..1c21f8da86 100644 --- a/scripts/scan_packages_baseline.json +++ b/scripts/scan_packages_baseline.json @@ -1,5 +1,5 @@ { - "_comment": "scan_packages.py allowlist. Each entry is a CRITICAL/HIGH finding manually judged benign. Matched on (package, package-relative file, check, evidence_hash); evidence_hash is over the matched code with L: markers stripped, so version bumps and line shifts do not reopen an entry but changed code does. severity and evidence are for review only. Regenerate with --write-baseline AFTER reviewing every line.", + "_comment": "scan_packages.py allowlist (reviewed). Each entry is a CRITICAL/HIGH finding manually judged benign. Matched on (package, package-relative file, check, evidence_hash); evidence_hash is over the matched code with L: markers stripped, so version bumps and line shifts do not reopen an entry but changed code does. severity and evidence are for review only. Regenerate with --write-baseline AFTER reviewing every line.", "version": 1, "entries": [ { @@ -303,8 +303,8 @@ "file": "openai/_base_client.py", "check": "C2 polling/beaconing loop detected", "severity": "CRITICAL", - "evidence": "L264: while True: sha256:95ca67e46d42354ae650abbdc5b0d97df8b0ed43187800bf40f5690c3901b94b", - "evidence_hash": "a57d8d15fed0bf04f9967dcc18a18b80bb19f4095675bccbb78ac0450d7fce14" + "evidence": "L274: while True: sha256:90a38e5c1e26893c7c273354143612640e9a9c0f079d3e2b60612d79f24e80a6", + "evidence_hash": "1022e8e8649436ec64a98a9d9141d085452c49549fd2157b0278fc369a83ac66" }, { "package": "openai", @@ -319,8 +319,8 @@ "file": "openai/auth/_workload.py", "check": "Accesses cloud metadata/IMDS AND makes network calls", "severity": "CRITICAL", - "evidence": "IMDS: L96: url = \"http://169.254.169.254/metadata/identity/oauth2/token\" | L149: url = \"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity\"\nNetwork: L77: http_client: httpx.Client | None = None, | L108: with httpx.Client() as client: | L133: http_client: httpx.Client | None = None, | L155: with httpx.Client() as client: | L248: with httpx.Client() as client:", - "evidence_hash": "1581d9f4a23393e9af23fbe5ef9f66807b22c5b5a3f1fe167254c9ebee108567" + "evidence": "IMDS: L97: url = \"http://169.254.169.254/metadata/identity/oauth2/token\" | L150: url = \"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity\"\nNetwork: L78: http_client: httpx.Client | None = None, | L109: with httpx.Client() as client: | L134: http_client: httpx.Client | None = None, | L156: with httpx.Client() as client: | L251: exchange_client = DefaultHttpx2Client(follow_redirects=False) if self._use_httpx2 else httpx.Client()", + "evidence_hash": "9717e51cb961dc14c458955d91a1e48e3753997346ecea0106bded3a8d64bfe0" }, { "package": "openai", @@ -343,8 +343,8 @@ "file": "openai/resources/beta/responses/responses.py", "check": "C2 polling/beaconing loop detected", "severity": "CRITICAL", - "evidence": "L3999: while True: sha256:df298b6eaf3416589b79f4ef283f8fb76e54d505bfda8840673f8e6419117e2e", - "evidence_hash": "10ce5cb5a7097fcff4042ddcfb4802edda60aa4b7b113c8b926a52ddb76f78c2" + "evidence": "L4000: while True: sha256:f8ab538118daba9ec06e27399dbdc90a4521c3390e6a47a6348a1f180a83effd", + "evidence_hash": "31481ea83c687acc27144d72d3832d4fb98dd1c79fb5e0ddd85080de95997b9f" }, { "package": "openai", @@ -359,16 +359,16 @@ "file": "openai/resources/realtime/realtime.py", "check": "C2 polling/beaconing loop detected", "severity": "CRITICAL", - "evidence": "L310: while True: sha256:458198ff3d3f05870bf98c9564cbfd68c739e57b9bbe4120ed81e3eb6af74a05", - "evidence_hash": "a3165d21e46b3ce553795daeae53e8f80e8e89c5cb228e68e6dcaff54bca5a89" + "evidence": "L311: while True: sha256:5b63313072aae9ca28677e03426513ccf12221e4f4e0ea6c31efbe09790633b5", + "evidence_hash": "05e1af469d651b51673763a7c4cdf759af9472fb627b7b470adc28cc237bd650" }, { "package": "openai", "file": "openai/resources/responses/responses.py", "check": "C2 polling/beaconing loop detected", "severity": "CRITICAL", - "evidence": "L3950: while True: sha256:1ce0b5a388c747945cdfda1a71b77afdfd03ae840d7aa9fa62f02eb00aa5e29f", - "evidence_hash": "6de300ebb5e6e17cb51c89cbcdf08515a44655182f0776f0908a9d1043ebbcd7" + "evidence": "L3951: while True: sha256:d68ef896bf0743ca430cfacb9a3353da1f3b9c51c3a21b6450a07a32b55aa2ac", + "evidence_hash": "160eecdd79b521bffbe8476f782b69a0724c35d1b19376a7600807165fd54f9f" }, { "package": "openai", diff --git a/tests/python/test_torchcodec_torch_compat.py b/tests/python/test_torchcodec_torch_compat.py new file mode 100644 index 0000000000..6ad16a73f4 --- /dev/null +++ b/tests/python/test_torchcodec_torch_compat.py @@ -0,0 +1,129 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +"""torch / torchcodec ABI guardrails (unslothai/unsloth#7225).""" + +from __future__ import annotations + +import importlib.util +import re +import sys +import types +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +PYPROJECT = REPO_ROOT / "pyproject.toml" +IMPORT_FIXES_PATH = REPO_ROOT / "unsloth" / "import_fixes.py" + + +def _load_import_fixes_module(): + spec = importlib.util.spec_from_file_location( + "unsloth_import_fixes_under_test", + IMPORT_FIXES_PATH, + ) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_pyproject_declares_torch210_audio_extra_with_python_gate(): + text = PYPROJECT.read_text(encoding = "utf-8") + assert "audio-torch210 = [" in text + assert "torchcodec>=0.10.0,<0.11.0" in text + assert "python_version >= '3.10'" in text + assert "audio-torch290 = [" in text + assert "audio-torch280 = [" in text + assert "\naudio = [" not in text + + +def _stub_torch(monkeypatch, version: str): + torch_mod = types.ModuleType("torch") + torch_mod.__version__ = version + monkeypatch.setitem(sys.modules, "torch", torch_mod) + + +def test_torch210_extras_bundle_audio_torch210(): + text = PYPROJECT.read_text(encoding = "utf-8") + 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) + + +def test_torchcodec_matrix_matches_notebook_validator(): + from scripts import notebook_validator as nv + fixes = _load_import_fixes_module() + assert fixes._TORCH_TORCHCODEC_MINORS == nv.TORCH_TORCHCODEC + + +def test_torchcodec_exclusive_upper_bound(): + fixes = _load_import_fixes_module() + assert fixes._torchcodec_exclusive_upper("0.10") == "<0.11.0" + assert fixes._torchcodec_exclusive_upper("0.9") == "<0.10.0" + + +def test_torch290_rejects_torchcodec_07(monkeypatch): + import importlib.metadata + + fixes = _load_import_fixes_module() + _stub_torch(monkeypatch, "2.9.0+cu128") + monkeypatch.setattr(importlib.metadata, "version", lambda _name: "0.7.0") + + hint = fixes._torchcodec_version_mismatch_hint() + assert hint is not None + assert "audio-torch210" not in hint + + +def test_torch280_accepts_torchcodec_07(monkeypatch): + import importlib.metadata + + fixes = _load_import_fixes_module() + _stub_torch(monkeypatch, "2.8.0+cu128") + monkeypatch.setattr(importlib.metadata, "version", lambda _name: "0.7.0") + + assert fixes._torchcodec_version_mismatch_hint() is None + + +def test_torch210_rejects_torchcodec_011(monkeypatch): + import importlib.metadata + + fixes = _load_import_fixes_module() + _stub_torch(monkeypatch, "2.10.0+cu128") + monkeypatch.setattr( + importlib.metadata, + "version", + lambda _name: "0.11.0", + ) + + hint = fixes._torchcodec_version_mismatch_hint() + assert hint is not None + assert "torchcodec 0.11.0" in hint + assert "audio-torch210" in hint + assert "<0.11.0" in hint + assert "<11.0" not in hint + + +def test_torch210_accepts_torchcodec_010(monkeypatch): + import importlib.metadata + + fixes = _load_import_fixes_module() + _stub_torch(monkeypatch, "2.10.0+cu128") + monkeypatch.setattr( + importlib.metadata, + "version", + lambda _name: "0.10.0+cu128", + ) + + assert fixes._torchcodec_version_mismatch_hint() is None + + +def test_import_fixes_loads_on_python39_syntax(): + """Regression: module must import on 3.9 (postponed annotations for str | None).""" + fixes = _load_import_fixes_module() + assert callable(fixes._torchcodec_version_mismatch_hint) diff --git a/unsloth/import_fixes.py b/unsloth/import_fixes.py index 5d54815705..9cd5e7243a 100644 --- a/unsloth/import_fixes.py +++ b/unsloth/import_fixes.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import os import importlib.abc import importlib.machinery @@ -1525,6 +1527,59 @@ def patch_torchcodec_audio_decoder(): pass +# torch.minor -> compatible torchcodec.minor strings (see notebook_validator.py). +_TORCH_TORCHCODEC_MINORS: dict[str, set[str]] = { + "2.10": {"0.10"}, + "2.9": {"0.8", "0.9"}, + "2.8": {"0.6", "0.7"}, + "2.7": {"0.3", "0.4", "0.5"}, + "2.6": {"0.2", "0.3"}, + "2.5": {"0.1", "0.2"}, +} + + +def _torchcodec_exclusive_upper(pin: str) -> str: + """Next torchcodec minor as an exclusive pip upper bound (0.10 -> <0.11.0).""" + major, minor = pin.split(".", 1) + return f"<{major}.{int(minor) + 1}.0" + + +def _torchcodec_version_mismatch_hint() -> str | None: + """Return a user-facing hint when installed torchcodec mismatches torch.""" + try: + import importlib.metadata as importlib_metadata + import torch + from packaging.version import Version + + torchcodec_version = importlib_metadata.version("torchcodec") + except Exception: + return None + + def _minor(version: str) -> str: + parts = Version(version.split("+", 1)[0]).release + return ".".join(str(p) for p in parts[:2]) + + try: + torch_minor = _minor(torch.__version__) + codec_minor = _minor(torchcodec_version) + except Exception: + # Non-PEP440 version strings must never break `import unsloth`. + return None + allowed = _TORCH_TORCHCODEC_MINORS.get(torch_minor) + if allowed is None or codec_minor in allowed: + return None + + pin = sorted(allowed)[-1] + upper = _torchcodec_exclusive_upper(pin) + install_hint = f"`pip install 'torchcodec>={pin},{upper}'`" + if torch_minor == "2.10": + install_hint += " or `pip install 'unsloth[audio-torch210]'`" + return ( + f"torchcodec {torchcodec_version} is incompatible with torch {torch.__version__}; " + f"install a matching build with {install_hint}." + ) + + def disable_torchcodec_if_broken(): """Make broken torchcodec behave as if uninstalled (#5446). @@ -1533,6 +1588,15 @@ def disable_torchcodec_if_broken(): flags and seat a sys.modules sentinel so downstream imports fall through their existing except ImportError handlers cleanly. """ + mismatch_hint = _torchcodec_version_mismatch_hint() + if mismatch_hint is not None: + try: + import warnings + warnings.warn(mismatch_hint, stacklevel = 2) + except Exception: + # Warning filters promoted to errors must not abort the disable + # fallback below (e.g. PYTHONWARNINGS=error, pytest -W error). + pass try: import importlib.util if importlib.util.find_spec("torchcodec") is None: