From a56c959233863b0cdad90f076c8be7f9a6a3a4c5 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 6 May 2026 04:41:57 -0700 Subject: [PATCH] Add Studio PR-time CI: pin enforcement, frontend, backend, wheel smoke (#5298) * Add Studio PR-time CI: pin enforcement, frontend, backend, wheel smoke The repo currently has no PR-time CI; only release-desktop.yml (manual) and stale.yml (issue pinger). studio/backend/tests/ has 35 test files (~860 tests collected) that never run automatically. Frontend lint/typecheck/build scripts exist in package.json but are not gated on PRs either. This is the gap that let 2026.5.1 ship with the broken Studio chat-history bundle. Adds four ubuntu-latest workflows, all CPU-only and free for public repos: studio-pin-enforce.yml Greps studio/frontend/package.json for caret/tilde ranges on the @assistant-ui surface (and assistant-stream). Blocks the exact regression vector that produced 2026.5.1 (^0.12.19 resolving to a breaking 0.12.28). studio-frontend-ci.yml npm ci (strict lockfile), tree-clean check after, typecheck, vite build, bundle grep for the Studio unstable_Provider call site (<= 3 hits = OK, >= 4 = the 2026.5.1 regression), 75 MB dist budget, biome non-blocking. Uploads dist on failure. studio-backend-ci.yml Runs the existing studio/backend/tests/ suite on Python 3.10/3.11/3.12. Excludes test_studio_api.py (live model + GGUF download) and llama_cpp_load_progress_live (spawns a real llama.cpp). Local run on this branch: 861 pass, 4 skipped, 5 deselected. ruff non-blocking. wheel-smoke.yml python -m build, then verifies the produced wheel: - ships studio/frontend/package-lock.json - ships studio/frontend/dist/index.html - does NOT ship studio/frontend/node_modules/ - does NOT ship studio/frontend/bun.lock - main JS bundle has < 4 unstable_Provider hits Then installs the wheel into a fresh venv with a lightweight dep set and imports studio.backend.main. Locally validated against the wheel built from this branch. Each workflow has concurrency cancellation on the same ref. biome and ruff are gated as non-blocking until the existing accumulated drift is cleared (~470 biome errors today); remove the bypass in a follow-up. Notes verified locally: - pin enforcement: PASS (carets dropped on this branch) - frontend npm ci -> typecheck -> build -> grep -> budget: PASS - bundle: 48 MB, hits=1 - backend pytest: 861 pass, 1 GPU-pollution failure not reproducible on GPU-less runners (won't reproduce on ubuntu-latest) - wheel build: 13s, produces unsloth-2026.5.2-py3-none-any.whl - wheel content sanity: all five checks PASS * CI: install full backend dep set + refine pytest filter for CPU runners First CI run on PR #5298 surfaced two real gaps: 1. pytest collection failed at `import yaml` in utils/models/model_config. Locally my workspace venv had pyyaml from a transitive; CI's clean Python 3.10/3.11/3.12 didn't, so collection hit ModuleNotFoundError on the very first test module. Same blew up the wheel-smoke `from studio.backend.main import app` step. 2. Once the import chain was complete, ~9 tests still failed because they exercise GPU-only paths or live transformers introspection that can't run on a GPU-less `ubuntu-latest` runner regardless of code correctness: - TestGpuAutoSelection - TestPreSpawnGpuResolution - TestPerGpuFitGuardAllCounts - TestTransformersIntrospection - test_returns_cuda_when_cuda_available - test_calls_cuda_cache_when_cuda Fix: - Backend CI installs `studio/backend/requirements/studio.txt` (the declared backend dep set) + the extras the import chain needs but studio.txt omits (python-multipart, sqlalchemy, cryptography, pyyaml, jinja2, mammoth, unpdf, requests, etc.) + torch CPU wheel + transformers. - Refine the pytest -k filter to deselect the GPU/introspection-bound classes by name. Deselections are commented inline with the reason. - wheel-smoke uses the same dep set so the import smoke matches. Locally validated against the freshly-built unsloth-2026.5.2 wheel: 831 passed, 5 skipped, 35 deselected, 0 failed in 47s Studio backend imports cleanly in a fresh venv after the wheel install. * CI: collapse multiline pytest -k expression to a single line YAML's | block-scalar fed the newlines verbatim into the -k argument and pytest rejected it as 'Wrong expression passed to -k'. Same logical filter on one line. * CI: rename jobs so the GitHub UI shows what each check actually does Adds a per-job 'name:' to all four workflows so the PR check list reads: Studio pin enforcement / @assistant-ui must be pinned exactly Studio frontend CI / Frontend build + bundle sanity Studio backend CI / Backend pytest (Python 3.10|3.11|3.12) Studio backend CI / Backend ruff lint (non-blocking) Wheel build + smoke / Wheel build + content sanity + import smoke Instead of the default ' / ' which was opaque ('check', 'build', 'pytest (3.10)', 'ruff', 'wheel'). * CI: add Python 3.13 to backend pytest matrix Verified locally: 831 backend tests pass under Python 3.13 with the same filter set used for 3.10 / 3.11 / 3.12. * CI: add Studio inference smoke + Tauri build smoke Two new workflows. Both CPU-only, both free on `ubuntu-latest`. studio-inference-smoke.yml The only workflow we have that proves "Studio actually works", as opposed to "the bundle parses" or "the imports succeed": - runs install.sh --local --no-torch (lean Studio install) - downloads unsloth/gemma-4-E2B-it-GGUF UD-IQ3_XXS into actions/cache - boots Studio in api-only mode - logs in with the bootstrap password, changes it, re-logs - POST /api/inference/load on the GGUF - POST /api/inference/chat/completions and asserts a non-empty assistant response Validated end-to-end locally on a fresh main install: model loaded, chat completion returned `Hello!` against the same GGUF the workflow uses. studio-tauri-smoke.yml PR-time variant of release-desktop.yml. Linux-only debug build (`tauri build --debug --no-bundle`) on ubuntu-22.04. Catches src-tauri Cargo.toml / Rust source breakage, tauri.conf.json drift, and frontend-distDir wiring. Pinned to the same Tauri CLI version (2.10.1) as release-desktop.yml so CLI bumps surface in CI before they break the release pipeline. Mac and Windows desktop builds stay manual via release-desktop.yml because they need code-signing secrets. * CI: use 'hf download' instead of deprecated 'huggingface-cli download' huggingface_hub 1.13.0 dropped the huggingface-cli entrypoint. The replacement is the 'hf' CLI shipped with the same package. Same args, just s/huggingface-cli/hf/. * CI: assert llama.cpp prebuilt path was used on ubuntu-latest The inference-smoke job runs on ubuntu-latest (CPU-only, x86_64), which is exactly the host shape that should pick up ggml-org/llama.cpp's bin-ubuntu-x64.tar.gz prebuilt directly. If install.sh ever falls back to a source build on this runner, the studio/setup.sh routing has regressed and every CPU-only Linux user is paying a 3 minute compile cost again. Tee install.sh output to logs/install.log, then fail the job if the log contains "falling back to source build" or is missing the success marker "prebuilt installed and validated" / "prebuilt up to date and validated". Also include logs/install.log in the failure artifact so the prebuilt diagnostics are uploaded alongside studio.log when the job fails. * Tighten prebuilt-assertion comment in studio-inference-smoke * CI: switch inference-smoke model to Qwen3.5-2B UD-IQ3_XXS Drops the Gemma 4 E2B GGUF (~2.3 GB) for unsloth/Qwen3.5-2B-GGUF (UD-IQ3_XXS, ~890 MiB). Cache-miss download is roughly a third of what it was, and CPU inference on ubuntu-latest finishes well inside the 25 minute job budget. Verified locally: load via /api/inference/load returns status=loaded, is_gguf=true, supports_reasoning=true, supports_tools=true; chat completion returns a non-empty assistant message ("Hello!"). * CI: add workflow_dispatch to inference-smoke for manual cache pre-warm * CI: fold pin-enforce grep into studio-frontend-ci, drop standalone workflow The "@assistant-ui must be pinned exactly" check was its own ~7 second workflow, doing a single grep on studio/frontend/package.json. Move it into studio-frontend-ci.yml as a pre-install step (right after checkout, before any node setup so a violation fails fast). One fewer top-level check row on every PR, same coverage. Add a FIXME so this step is dropped once @assistant-ui/* and assistant-stream leave 0.x: on 1.x, caret ranges are conventional and this becomes overzealous. * CI: add Repo tests (CPU) job, mirroring unsloth-zoo PR #624 conftest The top-level tests/ tree was previously not run anywhere. 23 of its files are CPU-friendly with the right harness: pure-Python helpers, ast walks, installer logic, and CLI shape tests. Locally validated: 302 passed, 9 skipped, 12 deselected in ~7 seconds on Python 3.12. Three pieces: 1. tests/conftest.py -- GPU-free harness, mirrors the conftest landed in unslothai/unsloth-zoo PR #624. Pre-loads unsloth_zoo.device_type and unsloth.device_type under a temporarily-mocked torch.cuda.is_available() so each module's @cache permanently captures "cuda" and the import chain succeeds on a CPU runner. Also stubs torch.cuda.get_device_capability / is_bf16_supported / mem_get_info, which unsloth/__init__.py and unsloth_zoo.temporary_patches probe at import time when DEVICE_TYPE == "cuda". On a real accelerator the harness is skipped and detection runs normally. 2. Two existing tests were leaking sys.modules state across the session because they injected stubs without an __spec__ and without restoration: - tests/test_raw_text.py shoved a "datasets" stub into sys.modules. transformers' import_utils later did importlib.util.find_spec("datasets") and got ValueError: datasets.__spec__ is None. - tests/python/test_fast_sentence_transformer_redirect_lifecycle.py shoved "transformers", "sentence_transformers", and "sentence_transformers.models" stubs in. Subsequent tests that did `import transformers` got the non-package stub. Fix: set __spec__ on stubs, plus an autouse fixture in the sentence-transformer test file that restores the three keys after each test. 3. .github/workflows/studio-backend-ci.yml gains a third job, `Repo tests (CPU)`, that installs the same dep set as the backend-pytest matrix (Python 3.12 only -- the tests are version-independent), exports PYTHONPATH=studio so tests/python/* can import install_python_stack, and runs the 23-file subset above with `-m 'not server and not e2e'`. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * CI: install unsloth_zoo for Repo CPU tests, harden conftest fallback The CPU job at run 25422050018 broke at conftest collection: the preload of unsloth.device_type pulled in `from unsloth_zoo.utils import Version` and ubuntu-latest didn't have unsloth_zoo on the path because it is an optional dep of unsloth. Two fixes: 1. Install unsloth_zoo>=2026.5.1 alongside the other deps in the Repo tests (CPU) job (it's also what unsloth's optional `huggingface` extra pins). 2. Wrap the body of _preload_device_type in conftest.py in a try/except so any import failure (missing prereq, broken module, etc.) cleanly returns False instead of aborting the entire collection. The caller already falls back to the stub device_type module on False, so the net behavior is "best effort: real device_type if possible, stub otherwise" instead of "abort the test session". * kernels.utils: guard CUDA_STREAMS / XPU_STREAMS init for DEVICE_COUNT==0 When DEVICE_COUNT is 0 (CPU host: no visible NVIDIA / AMD / Intel GPU) the dict comprehension {... for i in range(0)} was empty and the subsequent max(_CUDA_STREAMS.keys()) raised ValueError: max() iterable argument is empty during module import. That made unsloth.kernels.utils unimportable on any CPU runner, which in turn blocked all of tests/saving/**, three top-level tests/test_*.py, and tests/qlora/test_unsloth_qlora_train_and_merge.py from even collecting on CPU CI. Wrap the per-device-index dict comprehension and max() machinery in a DEVICE_COUNT > 0 guard. When DEVICE_COUNT is 0 fall back to empty containers (CUDA_STREAMS = (), WEIGHT_BUFFERS = [], ABSMAX_BUFFERS = []). The consumer functions further down in this module index these arrays by device_index but only during real GPU work, so the empty fallbacks never get touched on a CPU host. GPU-safety verified locally: with 8 visible CUDA devices, CUDA_STREAMS has 8 entries (identical to before this PR). With CUDA_VISIBLE_DEVICES="" the module imports cleanly, CUDA_STREAMS is (), and the previously blocked tests now collect (test_get_model_name passes 38 subtests, test_resolve_model_class passes 9, test_model_registry collects all 8 parametrizations). Same shape applied to the DEVICE_TYPE == "xpu" branch for symmetry. * CI: switch Repo tests (CPU) to auto-discovery + isolate flakes Three changes, locally validated end-to-end (779 passed, 11 skipped, 23 deselected, 0 failed across all three steps): 1. Repo tests (CPU, auto-discovered): replace the explicit 23-file list with `pytest tests/` plus a small set of `--ignore` and `--deselect` flags. New tests under tests/python, tests/studio (excluding the two state-sensitive files), and top-level tests/test_*.py are picked up automatically with no workflow edit. --ignore covers: - tests/qlora and tests/saving: GPU-bound by design - tests/utils: helpers folder, not tests - tests/sh: shell suite handled in its own step - two state-polluting hardware-spoof files (next step) -m 'not server and not e2e': honours markers already declared in tests/python/conftest.py --deselect: test_model_registration / test_all_model_registration hit huggingface_hub live; they belong on a network job 2. Hardware-spoof tests (state-sensitive, run in isolation): tests/studio/test_hardware_dispatch_matrix.py and tests/studio/test_is_mlx_dispatch_gate.py mutate module globals in studio.backend.utils.hardware.hardware (IS_ROCM, DEVICE) via their spoof fixtures, and the leak crosses file boundaries. Running them in their own pytest invocation avoids polluting the main sweep. Both pass cleanly in isolation: 28 passed, 1 skipped. 3. Shell installer tests: explicitly enumerated subset that does not depend on install.ps1 layout (test_install_host_defaults.sh has drifted; that's a separate followup). Test fixes folded in to keep the run green: - tests/studio/install/test_rocm_support.py::TestAmdGpuMonitoring ::test_amd_primary_gpu_with_mock now clears HIP/ROCR/CUDA_VISIBLE_DEVICES via monkeypatch so _first_visible_amd_gpu_id() does not short-circuit when the runner sets CUDA_VISIBLE_DEVICES="" to suppress CUDA. - tests/studio/test_hardware_dispatch_matrix.py::spoof_hardware fixture now stubs torch.cuda.get_device_properties when cuda_available is True so detect_hardware()'s device_name probe does not call into _cuda_init() on a CPU runner. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * CI: install torchvision (CPU) so unsloth_zoo.vision_utils can import Run 25430652224 collected three test modules that import unsloth and crashed at unsloth_zoo/vision_utils.py:68 with ModuleNotFoundError: No module named 'torchvision' unsloth_zoo.vision_utils unconditionally imports torchvision at module scope, and unsloth.models._utils pulls vision_utils in. The Repo tests (CPU) job installed torch from the CPU index but not torchvision, so any test that imports unsloth.models.* failed at collection. Add torchvision<0.26 to the same pip install --index-url https://download.pytorch.org/whl/cpu line. * CI: install bitsandbytes (CPU build) for unsloth.models._utils import Run 25430982243 collected three test modules that import unsloth and crashed at unsloth/models/_utils.py:1166 with ModuleNotFoundError: No module named 'bitsandbytes' The bnb import there is unconditional. Recent bnb versions (>=0.45) ship a CPU build so the wheel installs on a free Linux runner and the import resolves; the kernels still raise on use but the module collects, which is enough for these CPU tests. Add 'bitsandbytes>=0.45' to the Repo tests (CPU) deps. * CI: rename workflows + guard kernels.utils CPU-torch binding Workflow renames (top-level `name:` keys; affects PR check rows): Studio backend CI -> Backend CI Studio frontend CI -> Frontend CI Studio inference smoke -> Studio GGUF CI Studio Tauri smoke -> Studio Tauri CI Wheel build + smoke -> Wheel CI Backend CI's matrix job goes from "Backend pytest (Python 3.10)" to just "(Python 3.10)" so the GitHub UI row reads "Backend CI / (Python 3.10)" rather than the old verbose form. Production guard for CPU torch (run 25431126138): unsloth/kernels/utils.py:165 was an unconditional _gpu_getCurrentRawStream = torch._C._cuda_getCurrentRawStream which raised AttributeError on a CPU-only torch wheel because the compiled CUDA backend is absent. Three test modules (test_get_model_name, test_model_registry, test_resolve_model_class) crashed at collection because their import chain reaches this line. Add a hasattr probe: when torch is built without CUDA, fall through to a no-op binding that returns 0. _get_tensor_stream is only invoked during real GPU work, so the no-op is never executed on a CPU host. GPU-safety verified locally: with 8 visible CUDA devices the binding still resolves to the real torch._C._cuda_getCurrentRawStream (behaviour identical to before this PR). The XPU branch is untouched. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/workflows/studio-backend-ci.yml | 200 ++++++++++++++++++ .github/workflows/studio-frontend-ci.yml | 108 ++++++++++ .github/workflows/studio-inference-smoke.yml | 185 ++++++++++++++++ .github/workflows/studio-tauri-smoke.yml | 105 +++++++++ .github/workflows/wheel-smoke.yml | 124 +++++++++++ tests/conftest.py | 141 ++++++++++++ ...sentence_transformer_redirect_lifecycle.py | 40 +++- tests/studio/install/test_rocm_support.py | 13 +- tests/studio/test_hardware_dispatch_matrix.py | 14 ++ tests/test_raw_text.py | 5 +- unsloth/kernels/utils.py | 75 ++++--- 11 files changed, 977 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/studio-backend-ci.yml create mode 100644 .github/workflows/studio-frontend-ci.yml create mode 100644 .github/workflows/studio-inference-smoke.yml create mode 100644 .github/workflows/studio-tauri-smoke.yml create mode 100644 .github/workflows/wheel-smoke.yml create mode 100644 tests/conftest.py diff --git a/.github/workflows/studio-backend-ci.yml b/.github/workflows/studio-backend-ci.yml new file mode 100644 index 0000000000..5a858888e7 --- /dev/null +++ b/.github/workflows/studio-backend-ci.yml @@ -0,0 +1,200 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Runs the existing studio/backend/tests/ suite (~860 tests, all CPU-friendly) +# on every PR that touches the backend or unsloth library. Until this lands, +# none of those tests run automatically. Verified locally on Python 3.13 with +# the surgical exclusions below: 861 pass, 4 skipped. +# +# Exclusions: +# - tests/test_studio_api.py: end-to-end against a live model + GGUF download, +# too heavy for free runners. Run separately when GPU CI is available. +# - -k 'not llama_cpp_load_progress_live': spawns a real llama.cpp process, +# not appropriate for CPU-only runners. +# +# ruff is non-blocking initially; remove `|| true` once the backend lints clean. + +name: Backend CI + +on: + pull_request: + paths: + - 'studio/**' + - 'unsloth/**' + - 'unsloth_cli/**' + - 'tests/**' + - 'pyproject.toml' + - '.github/workflows/studio-backend-ci.yml' + push: + branches: [main, pip] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + pytest: + name: (Python ${{ matrix.python }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + python: ['3.10', '3.11', '3.12', '3.13'] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '${{ matrix.python }}' + cache: 'pip' + + - name: Install backend test dependencies (CPU only) + run: | + python -m pip install --upgrade pip + # Studio's declared backend deps: + pip install -r studio/backend/requirements/studio.txt + # Extras that studio.txt does not list but the import chain needs + # (python-multipart for FastAPI form/file uploads, sqlalchemy/cryptography + # for the auth DB, yaml/jinja2 for utils.models.model_config, etc.): + pip install \ + python-multipart aiofiles sqlalchemy cryptography \ + pyyaml jinja2 mammoth unpdf requests \ + 'numpy<3' pytest pytest-asyncio httpx + # Torch CPU + transformers are required by a chunk of the backend test + # suite (gpu_selection, kv_cache_estimation, utils). CPU-only torch + # keeps the install ~250 MB / ~1 min on a clean runner. + pip install --index-url https://download.pytorch.org/whl/cpu 'torch>=2.4,<2.11' + pip install 'transformers>=4.51,<5.5' + + - name: Backend tests + working-directory: studio/backend + # Locally validated against this dep set: 831 passed, 5 skipped, 35 deselected. + # Deselections (all environment-specific, would never pass on a GPU-less + # `ubuntu-latest` runner regardless of code correctness): + # - llama_cpp_load_progress_live: spawns a real llama.cpp process + # - TestGpuAutoSelection / TestPreSpawnGpuResolution / TestPerGpuFitGuardAllCounts: + # require live transformers config introspection on real GPUs + # - TestTransformersIntrospection: same + # - test_returns_cuda_when_cuda_available / test_calls_cuda_cache_when_cuda: + # assume CUDA-capable GPU + run: | + python -m pytest tests/ -q --tb=short \ + --ignore=tests/test_studio_api.py \ + -k 'not llama_cpp_load_progress_live and not TestGpuAutoSelection and not TestPreSpawnGpuResolution and not TestPerGpuFitGuardAllCounts and not TestTransformersIntrospection and not test_returns_cuda_when_cuda_available and not test_calls_cuda_cache_when_cuda' + + repo-cpu-tests: + # Auto-discover everything under tests/ that is not GPU-bound by + # design. New tests added in covered directories are picked up + # without a workflow edit. Locally validated: 779 passed, 11 + # skipped, 23 deselected. tests/conftest.py (mirroring unsloth-zoo + # PR #624) pre-loads unsloth_zoo.device_type and unsloth.device_type + # under a mocked torch.cuda.is_available so the unsloth import + # chain succeeds on CPU. + name: Repo tests (CPU) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install deps (shared shape with backend pytest job) + run: | + python -m pip install --upgrade pip + pip install -r studio/backend/requirements/studio.txt + pip install \ + python-multipart aiofiles sqlalchemy cryptography \ + pyyaml jinja2 mammoth unpdf requests typer \ + 'numpy<3' pytest pytest-asyncio httpx + # torchvision is needed because unsloth_zoo.vision_utils imports + # it at module scope and is reached via unsloth.models._utils. + pip install --index-url https://download.pytorch.org/whl/cpu \ + 'torch>=2.4,<2.11' 'torchvision<0.26' + pip install 'transformers>=4.51,<5.5' + # bitsandbytes is a hard import in unsloth/models/_utils.py. + # Recent versions ship a CPU build so it installs on a free + # Linux runner; the kernels still raise on use, but import + # succeeds and the package collects. + pip install 'bitsandbytes>=0.45' + # unsloth.device_type imports unsloth_zoo.utils.Version at module + # scope, so the conftest harness needs unsloth_zoo on the path + # even though it is an optional dep of unsloth. + pip install 'unsloth_zoo>=2026.5.1' + pip install -e . --no-deps + + - name: Repo tests (CPU, auto-discovered) + env: + # tests/python/* import install_python_stack from studio/. + PYTHONPATH: ${{ github.workspace }}/studio + # Skip lazy compilation work the unsloth import chain wants to + # do at import time on a real GPU. + UNSLOTH_COMPILE_DISABLE: '1' + # --ignore: GPU-bound directories (qlora and saving need real + # weights / GPU; tests/sh is a shell suite the next step + # handles; tests/utils is a helpers folder, not tests). + # State-sensitive hardware-spoofing files are pulled out and run + # in isolation in the next step because they mutate + # hardware.py module globals (IS_ROCM / DEVICE) and pollute + # downstream tests. + # -m: honour markers already declared in tests/python/conftest.py + # (`server` = needs studio venv, `e2e` = needs network). + # --deselect: two registry tests that hit huggingface_hub for + # live model existence checks; they belong on a network job. + run: | + python -m pytest tests/ -q --tb=short \ + --ignore=tests/qlora \ + --ignore=tests/saving \ + --ignore=tests/utils \ + --ignore=tests/sh \ + --ignore=tests/studio/test_hardware_dispatch_matrix.py \ + --ignore=tests/studio/test_is_mlx_dispatch_gate.py \ + -m 'not server and not e2e' \ + --deselect tests/test_model_registry.py::test_model_registration \ + --deselect tests/test_model_registry.py::test_all_model_registration + + - name: Hardware-spoof tests (state-sensitive, run in isolation) + env: + PYTHONPATH: ${{ github.workspace }}/studio + UNSLOTH_COMPILE_DISABLE: '1' + # These two files mutate hardware.py module globals at runtime + # via the spoof fixtures, which leaks state into any other test + # that imports hardware. Run them in their own pytest invocation + # so the leak does not cross file boundaries. + run: | + python -m pytest -q --tb=short \ + tests/studio/test_hardware_dispatch_matrix.py \ + tests/studio/test_is_mlx_dispatch_gate.py + + - name: Shell installer tests + # Subset that does not depend on a writable / pristine install.sh + # tree; test_install_host_defaults.sh checks install.ps1 layout + # which has drifted (separate followup). + run: | + set -e + for s in \ + tests/sh/test_get_torch_index_url.sh \ + tests/sh/test_mac_intel_compat.sh \ + tests/sh/test_tauri_install_exit_order.sh \ + tests/sh/test_torch_constraint.sh; do + echo "::group::$s" + bash "$s" + echo "::endgroup::" + done + + ruff: + name: Backend ruff lint (non-blocking) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + - run: pip install ruff + - name: ruff check (non-blocking until accumulated drift is cleared) + run: ruff check studio/backend || true diff --git a/.github/workflows/studio-frontend-ci.yml b/.github/workflows/studio-frontend-ci.yml new file mode 100644 index 0000000000..039bd5dd08 --- /dev/null +++ b/.github/workflows/studio-frontend-ci.yml @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Frontend PR gate: lockfile freshness, typecheck, build, and a bundle grep +# that catches the 2026.5.1 chat-history regression at the JS level. +# +# biome runs as non-blocking for now: the codebase currently has accumulated +# ~470 errors and ~1650 warnings against the existing biome config. Surfacing +# the count in CI lets us drive it down without forcing a fleet-wide cleanup +# in the same PR. Drop `continue-on-error` once that number is zero. + +name: Frontend CI + +on: + pull_request: + paths: + - 'studio/frontend/**' + - '.github/workflows/studio-frontend-ci.yml' + push: + branches: [main, pip] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Frontend build + bundle sanity + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: studio/frontend + steps: + - uses: actions/checkout@v4 + + # FIXME: drop this step once @assistant-ui/* and assistant-stream + # leave 0.x -- on 1.x, caret ranges are conventional. Until then, + # every 0.minor on this surface is a SemVer-major (this is exactly + # how 2026.5.1 shipped a broken chat runtime: ^0.12.19 quietly + # resolved to 0.12.28). + - name: '@assistant-ui must be pinned exactly (no caret/tilde)' + working-directory: ${{ github.workspace }} + run: | + set -e + if grep -nE '"(@assistant-ui/[a-z-]+|assistant-stream)":[[:space:]]*"[\^~]' studio/frontend/package.json; then + echo "::error file=studio/frontend/package.json::These packages must be pinned to exact versions until they leave 0.x. Drop the leading ^ or ~." + exit 1 + fi + echo "All assistant-ui packages are pinned exactly." + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: studio/frontend/package-lock.json + + - name: Lockfile must agree with package.json (npm ci is strict) + run: npm ci --no-fund --no-audit + + - name: npm ci must not have modified the working tree + working-directory: ${{ github.workspace }} + run: | + if ! git diff --quiet -- studio/frontend; then + echo "::error::npm ci modified files; commit the updated lockfile" + git status -- studio/frontend + exit 1 + fi + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Built bundle must not contain Studio's unstable_Provider call site + run: | + set -e + JS=$(ls dist/assets/index-*.js | head -1) + HITS=$(grep -c 'unstable_Provider:' "$JS" || echo 0) + echo "main bundle: $JS" + echo "unstable_Provider: hits=$HITS (assistant-ui internals contribute up to 3)" + if [ "$HITS" -gt 3 ]; then + echo "::error file=studio/frontend/src/features/chat/runtime-provider.tsx::Studio bundle still passes unstable_Provider through useRemoteThreadListRuntime; this is the 2026.5.1 chat-history regression. Pass adapters directly into useLocalRuntime instead." + exit 1 + fi + + - name: Bundle size budget (75 MB) + run: | + SIZE=$(du -sb dist | cut -f1) + BUDGET=$((75 * 1024 * 1024)) + echo "dist size: $SIZE bytes ($((SIZE/1024/1024)) MB), budget: $BUDGET bytes (75 MB)" + if [ "$SIZE" -gt "$BUDGET" ]; then + echo "::error::studio/frontend/dist/ exceeded the 75 MB budget. Drop dead deps (e.g. the unused next dep) or split chunks." + exit 1 + fi + + - name: Biome (non-blocking until accumulated drift is cleared) + continue-on-error: true + run: npm run biome:check + + - name: Upload built dist on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: studio-frontend-dist + path: studio/frontend/dist + retention-days: 3 diff --git a/.github/workflows/studio-inference-smoke.yml b/.github/workflows/studio-inference-smoke.yml new file mode 100644 index 0000000000..8efe072d28 --- /dev/null +++ b/.github/workflows/studio-inference-smoke.yml @@ -0,0 +1,185 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# End-to-end smoke: install Studio via install.sh --local --no-torch, download +# a tiny GGUF, boot Studio, log in, change password, load the model, send a +# chat completion, assert a non-empty response. Only workflow that tests "the +# app actually works". +# +# Model: Qwen3.5-2B UD-IQ3_XXS (~890 MiB) -- small enough that the cache miss +# is cheap and inference fits in the 25 min CPU-runner budget. GGUF is cached +# across runs via actions/cache. + +name: Studio GGUF CI + +on: + pull_request: + paths: + - 'studio/**' + - 'unsloth/**' + - 'unsloth_cli/**' + - 'install.sh' + - 'pyproject.toml' + - '.github/workflows/studio-inference-smoke.yml' + push: + branches: [main, pip] + # Manual trigger for pre-warming the GGUF cache on main, or re-running + # against an arbitrary branch without pushing a no-op commit. + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GGUF_REPO: unsloth/Qwen3.5-2B-GGUF + GGUF_FILE: Qwen3.5-2B-UD-IQ3_XXS.gguf + STUDIO_PORT: '18888' + +jobs: + inference: + name: Studio boots, loads a GGUF, answers a chat completion + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + + - name: Linux dependencies for llama.cpp prebuilt + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libcurl4-openssl-dev libssl-dev jq + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: studio/frontend/package-lock.json + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Cache GGUF model file + id: cache-gguf + uses: actions/cache@v4 + with: + path: gguf-cache + key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 + + - name: Download GGUF if cache miss + if: steps.cache-gguf.outputs.cache-hit != 'true' + run: | + # huggingface-cli was deprecated in huggingface_hub 1.13; the new CLI is `hf`. + python -m pip install --upgrade huggingface_hub hf_transfer + mkdir -p gguf-cache + HF_HUB_ENABLE_HF_TRANSFER=1 \ + hf download "$GGUF_REPO" "$GGUF_FILE" --local-dir gguf-cache + + - name: Install Studio (--local, --no-torch keeps the install lean) + run: | + mkdir -p logs + set -o pipefail + bash install.sh --local --no-torch 2>&1 | tee logs/install.log + + - name: Assert llama.cpp prebuilt was installed (no source-build fallback) + # ubuntu-latest is CPU-only x86_64, so studio/setup.sh should route + # to ggml-org/llama.cpp and grab bin-ubuntu-x64.tar.gz. A source + # build here means the routing regressed. + run: | + if grep -q "falling back to source build" logs/install.log; then + echo "::error::llama.cpp prebuilt path failed on ubuntu-latest. studio/setup.sh routing regressed; CPU-only Linux x86_64 should hit ggml-org/llama.cpp's bin-ubuntu-x64.tar.gz." + grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60 + exit 1 + fi + if ! grep -qE "prebuilt installed and validated|prebuilt up to date and validated" logs/install.log; then + echo "::error::install.log does not contain the success marker for the llama.cpp prebuilt path. Did setup.sh skip the prebuilt install?" + grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60 + exit 1 + fi + echo "llama.cpp prebuilt path used successfully" + + - name: Reset auth + start Studio in the background + run: | + unsloth studio reset-password + mkdir -p logs + UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ + > logs/studio.log 2>&1 & + echo "STUDIO_PID=$!" >> "$GITHUB_ENV" + + - name: Wait for /api/health + run: | + for i in $(seq 1 60); do + if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then + echo "ready after ${i}s" + cat /tmp/health.json + jq -e '.status == "healthy"' /tmp/health.json + exit 0 + fi + sleep 1 + done + echo "Studio did not become healthy in 60s" + tail -200 logs/studio.log + exit 1 + + - name: Login + change bootstrap password + run: | + PW=$(cat ~/.unsloth/studio/auth/.bootstrap_password) + NEW="CIPasswordSmoke12345!" + TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \ + -H 'content-type: application/json' \ + -d "{\"username\":\"unsloth\",\"password\":\"$PW\"}" | jq -r .access_token) + curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d "{\"current_password\":\"$PW\",\"new_password\":\"$NEW\"}" > /dev/null + # Re-login to clear must_change_password flag. + NEW_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \ + -H 'content-type: application/json' \ + -d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token) + echo "TOKEN=$NEW_TOKEN" >> "$GITHUB_ENV" + + - name: Load the GGUF into Studio + run: | + GGUF_PATH="$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" + ls -lh "$GGUF_PATH" + curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + --max-time 600 \ + -d "{\"model_path\":\"$GGUF_PATH\",\"is_lora\":false,\"max_seq_length\":2048}" \ + | jq '{status, display_name, is_gguf, context_length}' + + - name: Send a chat completion + assert non-empty response + run: | + RESP=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/chat/completions" \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + --max-time 900 \ + -d '{ + "messages":[{"role":"user","content":"Say hello in one short sentence."}], + "max_tokens":40, + "stream":false + }') + echo "raw response: $RESP" + CONTENT=$(echo "$RESP" | jq -r '.choices[0].message.content // empty') + echo "model response: $CONTENT" + if [ -z "$CONTENT" ]; then + echo "::error::Empty assistant response from Studio" + exit 1 + fi + + - name: Stop Studio + if: always() + run: | + kill "${STUDIO_PID}" || true + sleep 2 + ss -tln | grep ":${STUDIO_PORT}" || true + + - name: Upload Studio + install logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: studio-inference-log + path: | + logs/studio.log + logs/install.log + retention-days: 7 diff --git a/.github/workflows/studio-tauri-smoke.yml b/.github/workflows/studio-tauri-smoke.yml new file mode 100644 index 0000000000..fcc9c8d963 --- /dev/null +++ b/.github/workflows/studio-tauri-smoke.yml @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# PR-time smoke for the Tauri desktop wrapper. Builds the frontend and the +# Tauri Linux debug binary, with no codesigning. Catches: +# - tauri.conf.json drift +# - src-tauri Cargo.toml or rust source breakage +# - Tauri CLI version drift (we pin 2.10.1, matching release-desktop.yml) +# - frontend output not picked up by Tauri's distDir +# +# Linux-only on a free `ubuntu-latest` runner. Mac and Windows desktop builds +# stay in release-desktop.yml (manual `workflow_dispatch`) because they need +# code-signing secrets and ~30 min of runner time each. + +name: Studio Tauri CI + +on: + pull_request: + paths: + - 'studio/frontend/**' + - 'studio/src-tauri/**' + - '.github/workflows/studio-tauri-smoke.yml' + push: + branches: [main, pip] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + linux-debug-build: + name: Tauri Linux debug build (no codesign) + runs-on: ubuntu-22.04 + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + + - name: Linux native deps for Tauri / WebKit2GTK + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \ + librsvg2-dev libxdo-dev libssl-dev patchelf + + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: studio/frontend/package-lock.json + + - uses: dtolnay/rust-toolchain@stable + + - uses: swatinem/rust-cache@v2 + with: + workspaces: studio/src-tauri -> target + + - name: Install pinned Tauri CLI (matches release-desktop.yml) + run: npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1 + + - name: Verify pinned Tauri CLI version + run: | + out="$(npx --prefix studio tauri --version)" + echo "$out" + [ "$out" = "tauri-cli 2.10.1" ] || { echo "::error::expected tauri-cli 2.10.1, got $out"; exit 1; } + + - name: Frontend build (npm ci, vite) + working-directory: studio/frontend + run: | + npm ci --no-fund --no-audit + npm run build + test -f dist/index.html + + - name: Tauri debug build (Linux, no bundle, no codesign) + # `--debug` + `--no-bundle` keeps this lean: compiles the Rust crate, + # confirms the frontend dist is wired into Tauri, but skips the AppImage + # / .deb production. Code signing is irrelevant because we never produce + # a distributable artifact. + env: + TAURI_SIGNING_PRIVATE_KEY: '' + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '' + run: npx --prefix studio tauri build --debug --no-bundle + + - name: Inspect produced binary + run: | + BIN=$(find studio/src-tauri/target/debug -maxdepth 1 -type f -executable 2>/dev/null \ + | grep -Ev '\.(d|so|dylib|dll)$' \ + | grep -Ev '/(deps|build|examples)$' \ + | head -1) + echo "binary: $BIN" + if [ -z "$BIN" ]; then + echo "::error::Tauri debug binary not produced" + ls -la studio/src-tauri/target/debug/ || true + exit 1 + fi + file "$BIN" + du -h "$BIN" + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: tauri-debug-build + path: | + studio/src-tauri/target/debug + studio/frontend/dist + retention-days: 3 diff --git a/.github/workflows/wheel-smoke.yml b/.github/workflows/wheel-smoke.yml new file mode 100644 index 0000000000..080a6bb261 --- /dev/null +++ b/.github/workflows/wheel-smoke.yml @@ -0,0 +1,124 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Builds the PyPI wheel from the PR branch, then verifies the built wheel +# actually contains what we expect to ship and does NOT contain the broken +# Studio bundle that 2026.5.1 published. This is the single workflow that +# would have blocked the 2026.5.1 release before twine upload. +# +# Verified locally end-to-end against this branch: +# - python -m build produces unsloth--py3-none-any.whl in 13s +# - wheel content sanity passes: +# lockfile shipped, frontend dist shipped, +# no node_modules in wheel, no bun.lock in wheel, +# main bundle has unstable_Provider hits=1 (assistant-ui internals only). +# - Studio backend imports cleanly from the installed wheel with the +# lightweight dep set below. + +name: Wheel CI + +on: + pull_request: + paths: + - 'pyproject.toml' + - 'studio/**' + - 'unsloth/**' + - 'unsloth_cli/**' + - '.github/workflows/wheel-smoke.yml' + push: + branches: [main, pip] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + wheel: + name: Wheel build + content sanity + import smoke + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: studio/frontend/package-lock.json + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Build frontend + run: | + cd studio/frontend + npm ci --no-fund --no-audit + npm run build + + - name: Build wheel + sdist + run: | + python -m pip install --upgrade pip build + rm -rf dist build ./*.egg-info + python -m build + + - name: Wheel content sanity + run: | + python - <<'PY' + import zipfile, glob, sys + w = glob.glob("dist/unsloth-*.whl") + if not w: + print("FAIL: no wheel produced"); sys.exit(2) + w = w[0] + print(f"wheel: {w}") + with zipfile.ZipFile(w) as z: + n = z.namelist() + checks = { + "lockfile shipped": any(s.endswith("studio/frontend/package-lock.json") for s in n), + "frontend dist shipped": any(s.endswith("studio/frontend/dist/index.html") for s in n), + "no node_modules": not any("studio/frontend/node_modules/" in s for s in n), + "no bun.lock": not any(s.endswith("studio/frontend/bun.lock") for s in n), + } + js = [s for s in n + if "studio/frontend/dist/assets/" in s + and s.endswith(".js") + and "/index-" in s] + if not js: + print("FAIL: no main bundle index-*.js in wheel"); sys.exit(2) + data = z.read(js[0]).decode("utf-8", "replace") + hits = data.count("unstable_Provider:") + print(f"main bundle: {js[0]}") + print(f"unstable_Provider hits: {hits} (>=4 indicates 2026.5.1 regression)") + checks["bundle has no Studio unstable_Provider call site"] = (hits < 4) + + print() + for k, v in checks.items(): + print(f" [{'PASS' if v else 'FAIL'}] {k}") + sys.exit(0 if all(checks.values()) else 1) + PY + + - name: Studio backend import smoke + # Imports `studio.backend.main:app` from the freshly-installed wheel in + # a clean venv. This catches the class of bug that 2026.5.1 shipped with: + # frontend dist missing, package-lock.json missing, or the wheel's Python + # source tree broken in a way that surfaces only at app construction time. + run: | + python -m venv /tmp/v + /tmp/v/bin/pip install --upgrade pip + /tmp/v/bin/pip install -r studio/backend/requirements/studio.txt + /tmp/v/bin/pip install \ + python-multipart aiofiles sqlalchemy cryptography \ + pyyaml jinja2 mammoth unpdf requests \ + 'numpy<3' + /tmp/v/bin/pip install --no-deps dist/unsloth-*.whl + # Run from /tmp so Python imports the installed package, not the source tree. + cd /tmp + /tmp/v/bin/python -c "from studio.backend.main import app; print('Studio backend OK:', app.title)" + + - name: Upload wheel on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: unsloth-wheel + path: dist/ + retention-days: 7 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..de41c50fc1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +"""GPU-free test harness. + +unsloth's import chain hits unsloth_zoo.device_type, which calls +get_device_type() at import time and raises NotImplementedError on CI +runners with no CUDA / XPU / HIP visible. Pre-load the real +unsloth_zoo.device_type under a temporarily-mocked +torch.cuda.is_available() so its @cache permanently captures "cuda". +On a real accelerator the pre-load is skipped and detection runs +normally. + +Mirrors the conftest harness in unslothai/unsloth-zoo PR #624. +""" + +from __future__ import annotations + +import importlib.util +import os +import sys +import types + + +def _has_real_accelerator() -> bool: + try: + import torch + except Exception: + return False + for probe in ( + lambda: hasattr(torch, "cuda") and torch.cuda.is_available(), + lambda: hasattr(torch, "xpu") and torch.xpu.is_available(), + lambda: hasattr(torch, "accelerator") and torch.accelerator.is_available(), + ): + try: + if probe(): + return True + except Exception: + pass + return False + + +def _preload_device_type(package: str, prereqs: tuple[str, ...] = ()) -> bool: + """Pre-load .device_type under a mocked + torch.cuda.is_available() == True so its @cache permanently + captures "cuda". prereqs lists submodule names of that + must be loaded first (e.g. 'utils' for unsloth_zoo). Returns False + if the package or any prerequisite cannot be imported, in which + case the caller falls back to a stub.""" + target = f"{package}.device_type" + if target in sys.modules: + return True + pkg_spec = importlib.util.find_spec(package) + if pkg_spec is None or not pkg_spec.submodule_search_locations: + return False + pkg_path = pkg_spec.submodule_search_locations[0] + + skeleton_already = package in sys.modules + if not skeleton_already: + skel = types.ModuleType(package) + skel.__path__ = [pkg_path] + skel.__spec__ = pkg_spec + skel.__package__ = package + sys.modules[package] = skel + + try: + for prereq in prereqs: + full = f"{package}.{prereq}" + if full in sys.modules: + continue + prereq_path = os.path.join(pkg_path, f"{prereq}.py") + prereq_spec = importlib.util.spec_from_file_location(full, prereq_path) + prereq_mod = importlib.util.module_from_spec(prereq_spec) + sys.modules[full] = prereq_mod + prereq_spec.loader.exec_module(prereq_mod) + + device_type_path = os.path.join(pkg_path, "device_type.py") + dt_spec = importlib.util.spec_from_file_location(target, device_type_path) + dt_mod = importlib.util.module_from_spec(dt_spec) + sys.modules[target] = dt_mod + + import torch + + _orig_is_avail = torch.cuda.is_available + torch.cuda.is_available = lambda: True # type: ignore[assignment] + try: + dt_spec.loader.exec_module(dt_mod) + finally: + torch.cuda.is_available = _orig_is_avail + except Exception: + sys.modules.pop(target, None) + return False + finally: + if not skeleton_already: + sys.modules.pop(package, None) + + return True + + +def _patch_torch_cuda_for_import() -> None: + """Stub torch.cuda.* probes that fire at IMPORT time of unsloth / + unsloth_zoo when DEVICE_TYPE was forced to "cuda" above. These are + queries, not real GPU work, so returning plausible Ampere values + lets the import chain finish; tests that touch real tensors run on + CPU like normal.""" + try: + import torch.cuda.memory as _cuda_memory # type: ignore + + _cuda_memory.mem_get_info = lambda *a, **k: (0, 80 * 1024**3) + except Exception: + pass + try: + import torch + + torch.cuda.get_device_capability = lambda *a, **k: (8, 0) + torch.cuda.is_bf16_supported = lambda *a, **k: True + except Exception: + pass + + +def _install_device_type_stub(name: str) -> None: + stub = types.ModuleType(name) + stub.DEVICE_TYPE = "cuda" + stub.DEVICE_TYPE_TORCH = "cuda" + stub.DEVICE_COUNT = 1 + stub.ALLOW_PREQUANTIZED_MODELS = False + stub.is_hip = lambda: False + stub.get_device_type = lambda: "cuda" + stub.get_device_count = lambda: 1 + stub.device_synchronize = lambda *a, **k: None + stub.device_empty_cache = lambda *a, **k: None + stub.device_is_bf16_supported = lambda *a, **k: False + sys.modules[name] = stub + + +if not _has_real_accelerator(): + if not _preload_device_type("unsloth_zoo", prereqs = ("utils",)): + _install_device_type_stub("unsloth_zoo.device_type") + if not _preload_device_type("unsloth"): + _install_device_type_stub("unsloth.device_type") + _patch_torch_cuda_for_import() diff --git a/tests/python/test_fast_sentence_transformer_redirect_lifecycle.py b/tests/python/test_fast_sentence_transformer_redirect_lifecycle.py index ff9b91ec23..31d86b09a4 100644 --- a/tests/python/test_fast_sentence_transformer_redirect_lifecycle.py +++ b/tests/python/test_fast_sentence_transformer_redirect_lifecycle.py @@ -8,11 +8,45 @@ from __future__ import annotations +import importlib.util import os import pathlib import sys import types +import pytest + + +def _stub_module(name: str) -> types.ModuleType: + # __spec__ must be set so importlib.util.find_spec(name) does not raise + # ValueError if a downstream test imports the real package. + mod = types.ModuleType(name) + mod.__spec__ = importlib.util.spec_from_loader(name, loader = None) + return mod + + +_STUB_KEYS = ( + "transformers", + "sentence_transformers", + "sentence_transformers.models", +) + + +@pytest.fixture(autouse = True) +def _restore_sys_modules(): + """Snapshot the entries we shadow with stubs and restore them after each + test so a downstream test that does `import transformers` for real does + not pick up our non-package stub.""" + saved = {k: sys.modules.get(k) for k in _STUB_KEYS} + try: + yield + finally: + for k, v in saved.items(): + if v is None: + sys.modules.pop(k, None) + else: + sys.modules[k] = v + class _FakeAuto: def __init__(self, name): @@ -45,14 +79,14 @@ class _RaisingTransformer: def _build_driver(transformer_class): - transformers_mod = types.ModuleType("transformers") + transformers_mod = _stub_module("transformers") transformers_mod.AutoModel = _FakeAuto("AutoModel") transformers_mod.AutoProcessor = _FakeAuto("AutoProcessor") transformers_mod.AutoTokenizer = _FakeAuto("AutoTokenizer") sys.modules["transformers"] = transformers_mod - st_root = types.ModuleType("sentence_transformers") - st_models = types.ModuleType("sentence_transformers.models") + st_root = _stub_module("sentence_transformers") + st_models = _stub_module("sentence_transformers.models") st_models.Transformer = transformer_class sys.modules["sentence_transformers"] = st_root sys.modules["sentence_transformers.models"] = st_models diff --git a/tests/studio/install/test_rocm_support.py b/tests/studio/install/test_rocm_support.py index 553756cd4f..13df049c2d 100644 --- a/tests/studio/install/test_rocm_support.py +++ b/tests/studio/install/test_rocm_support.py @@ -1188,7 +1188,7 @@ class TestAmdGpuMonitoring: assert metrics["vram_utilization_pct"] is not None assert metrics["power_utilization_pct"] is not None - def test_amd_primary_gpu_with_mock(self): + def test_amd_primary_gpu_with_mock(self, monkeypatch): """get_primary_gpu_utilization returns correct dict with mocked amd-smi.""" amd_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "amd.py" _amd_spec = importlib.util.spec_from_file_location("test_amd2", amd_path) @@ -1203,6 +1203,17 @@ class TestAmdGpuMonitoring: except Exception: pytest.skip("Could not load amd module") + # _first_visible_amd_gpu_id() short-circuits to None when any of + # HIP / ROCR / CUDA_VISIBLE_DEVICES is set to "" or "-1". CI runners + # often unset CUDA at the env level by setting CUDA_VISIBLE_DEVICES + # to "" so the test must not inherit that. + for var in ( + "HIP_VISIBLE_DEVICES", + "ROCR_VISIBLE_DEVICES", + "CUDA_VISIBLE_DEVICES", + ): + monkeypatch.delenv(var, raising = False) + mock_json = json.dumps( [ { diff --git a/tests/studio/test_hardware_dispatch_matrix.py b/tests/studio/test_hardware_dispatch_matrix.py index 276c57ca5a..c7a6841936 100644 --- a/tests/studio/test_hardware_dispatch_matrix.py +++ b/tests/studio/test_hardware_dispatch_matrix.py @@ -208,6 +208,20 @@ def spoof_hardware(monkeypatch): # torch.cuda.is_available monkeypatch.setattr(torch.cuda, "is_available", lambda: profile.cuda_available) + # detect_hardware reads torch.cuda.get_device_properties(0).name when + # cuda_available is True. On a CPU CI runner that triggers _cuda_init + # and crashes with "No CUDA GPUs are available". Stub it so the + # dispatch path under test runs end-to-end. + if profile.cuda_available: + stub_props = types.SimpleNamespace( + name = "Stub GPU" if not profile.hip_version else "Stub AMD GPU", + ) + monkeypatch.setattr( + torch.cuda, + "get_device_properties", + lambda i = 0: stub_props, + raising = False, + ) # torch.version.hip — None on NVIDIA, "6.1" etc. on ROCm torch_version = torch.version diff --git a/tests/test_raw_text.py b/tests/test_raw_text.py index d8289fed20..056ea8660f 100644 --- a/tests/test_raw_text.py +++ b/tests/test_raw_text.py @@ -35,8 +35,11 @@ class MockDataset: return cls(data_dict) -# Mock datasets module +# Mock datasets module. __spec__ must be set so importlib.util.find_spec +# does not raise ValueError when transformers' import_utils probes for +# the real `datasets` package later in the test session. datasets_mock = type(sys)("datasets") +datasets_mock.__spec__ = importlib.util.spec_from_loader("datasets", loader = None) datasets_mock.Dataset = MockDataset sys.modules["datasets"] = datasets_mock diff --git a/unsloth/kernels/utils.py b/unsloth/kernels/utils.py index 09b03a597b..dd5a9cbf0e 100644 --- a/unsloth/kernels/utils.py +++ b/unsloth/kernels/utils.py @@ -161,8 +161,14 @@ else: if DEVICE_TYPE == "xpu": _gpu_getCurrentRawStream = torch._C._xpu_getCurrentRawStream # NVIDIA GPU Default Logic -else: +elif hasattr(torch._C, "_cuda_getCurrentRawStream"): _gpu_getCurrentRawStream = torch._C._cuda_getCurrentRawStream +else: + # CPU-only torch wheel (no compiled CUDA backend). _get_tensor_stream + # is only invoked during real GPU work, so a no-op binding is safe. + def _gpu_getCurrentRawStream(_index = 0): + return 0 + c_void_p = ctypes.c_void_p @@ -177,36 +183,49 @@ global XPU_STREAMS global WEIGHT_BUFFERS global ABSMAX_BUFFERS -# INTEL GPU Specific Logic +# DEVICE_COUNT == 0 = no visible accelerator (e.g. CPU-only CI runner). +# The consumer functions below only index these arrays during real GPU +# work, so empty containers are safe -- they just need to be defined so +# the module imports cleanly. if DEVICE_TYPE == "xpu": - _XPU_STREAMS = { - (index := torch.xpu.device(i).idx): ctypes.c_void_p( - torch._C._xpu_getCurrentRawStream(index) - ) - for i in range(DEVICE_COUNT) - } - XPU_STREAMS = [None] * (max(_XPU_STREAMS.keys()) + 1) - WEIGHT_BUFFERS = [None] * (max(_XPU_STREAMS.keys()) + 1) - ABSMAX_BUFFERS = [None] * (max(_XPU_STREAMS.keys()) + 1) - for k, v in _XPU_STREAMS.items(): - XPU_STREAMS[k] = v - XPU_STREAMS = tuple(XPU_STREAMS) - del _XPU_STREAMS + if DEVICE_COUNT > 0: + _XPU_STREAMS = { + (index := torch.xpu.device(i).idx): ctypes.c_void_p( + torch._C._xpu_getCurrentRawStream(index) + ) + for i in range(DEVICE_COUNT) + } + XPU_STREAMS = [None] * (max(_XPU_STREAMS.keys()) + 1) + WEIGHT_BUFFERS = [None] * (max(_XPU_STREAMS.keys()) + 1) + ABSMAX_BUFFERS = [None] * (max(_XPU_STREAMS.keys()) + 1) + for k, v in _XPU_STREAMS.items(): + XPU_STREAMS[k] = v + XPU_STREAMS = tuple(XPU_STREAMS) + del _XPU_STREAMS + else: + XPU_STREAMS = () + WEIGHT_BUFFERS = [] + ABSMAX_BUFFERS = [] else: # NVIDIA GPU Default Logic - _CUDA_STREAMS = { - (index := torch.cuda.device(i).idx): ctypes.c_void_p( - torch._C._cuda_getCurrentRawStream(index) - ) - for i in range(DEVICE_COUNT) - } - CUDA_STREAMS = [None] * (max(_CUDA_STREAMS.keys()) + 1) - WEIGHT_BUFFERS = [None] * (max(_CUDA_STREAMS.keys()) + 1) - ABSMAX_BUFFERS = [None] * (max(_CUDA_STREAMS.keys()) + 1) - for k, v in _CUDA_STREAMS.items(): - CUDA_STREAMS[k] = v - CUDA_STREAMS = tuple(CUDA_STREAMS) - del _CUDA_STREAMS + if DEVICE_COUNT > 0: + _CUDA_STREAMS = { + (index := torch.cuda.device(i).idx): ctypes.c_void_p( + torch._C._cuda_getCurrentRawStream(index) + ) + for i in range(DEVICE_COUNT) + } + CUDA_STREAMS = [None] * (max(_CUDA_STREAMS.keys()) + 1) + WEIGHT_BUFFERS = [None] * (max(_CUDA_STREAMS.keys()) + 1) + ABSMAX_BUFFERS = [None] * (max(_CUDA_STREAMS.keys()) + 1) + for k, v in _CUDA_STREAMS.items(): + CUDA_STREAMS[k] = v + CUDA_STREAMS = tuple(CUDA_STREAMS) + del _CUDA_STREAMS + else: + CUDA_STREAMS = () + WEIGHT_BUFFERS = [] + ABSMAX_BUFFERS = [] # Bitsandbytes operations ctypes_c_int = ctypes.c_int