unsloth/studio/backend/tests/test_cpu_threads.py
Michael Han 6d8c18cd1a
Replace standalone Studio wording with Unsloth (#7221)
* Replace standalone Studio wording with Unsloth

Replace the single word Studio with Unsloth wherever it is used as
shorthand for Unsloth Studio in docs, CLI output, UI strings, i18n
locales, workflow display names, comments and docstrings.

Kept unchanged: the full name Unsloth Studio, third party product
names (LM Studio, Visual Studio, Mac Studio), feature names
(Recipe Studio, Fine-tuning Studio and its translations), and all
identifiers such as env vars, commands, paths and filenames.

* Address review feedback on the Studio wording rename

Use "an" before Unsloth where the rename left the article as "a".
Restore the split brand where Unsloth and Studio render as two halves
of the full product name: the onboarding sidebar subtitle and the
IPv6 localhost warning. Scope two messages to the full name Unsloth
Studio where plain Unsloth was misleading: the AMD README bullet and
the CLI studio setup error.
2026-07-19 00:47:04 -07:00

152 lines
5.1 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
"""Tests for Unsloth's early CPU thread-pool configuration."""
import ast
import os
import subprocess
import sys
from pathlib import Path
import pytest
from utils.cpu_threads import _THREAD_POOL_ENV_VARS, configure_cpu_threads
_BACKEND_DIR = Path(__file__).resolve().parent.parent
_RUN_PY = _BACKEND_DIR / "run.py"
_MAIN_PY = _BACKEND_DIR / "main.py"
# Explicit positive integers seed all four native pool env vars.
def test_cpu_thread_cap_seeds_native_pool_limits():
env = {"UNSLOTH_CPU_THREADS": " 6 "}
configure_cpu_threads(env)
assert {variable: env[variable] for variable in _THREAD_POOL_ENV_VARS} == {
variable: "6" for variable in _THREAD_POOL_ENV_VARS
}
# Explicit per-library values win over the Unsloth knob via setdefault.
def test_cpu_thread_cap_preserves_runtime_specific_override():
env = {"UNSLOTH_CPU_THREADS": "4", "OMP_NUM_THREADS": "2"}
configure_cpu_threads(env)
assert env["OMP_NUM_THREADS"] == "2"
assert env["MKL_NUM_THREADS"] == "4"
# Whitespace / plus-prefix / leading zero all normalise via int().
@pytest.mark.parametrize("raw", ["+4", "007", " 4 "])
def test_cpu_thread_cap_normalises_valid_inputs(raw):
env = {"UNSLOTH_CPU_THREADS": raw}
configure_cpu_threads(env)
assert env["OMP_NUM_THREADS"] == str(int(raw.strip()))
# Unset / empty / whitespace -> no env mutation (pure opt-in).
@pytest.mark.parametrize("raw", [None, "", " ", "\t"])
def test_cpu_thread_cap_is_opt_in(raw):
env = {} if raw is None else {"UNSLOTH_CPU_THREADS": raw}
snapshot = dict(env)
configure_cpu_threads(env)
assert env == snapshot
assert all(variable not in env for variable in _THREAD_POOL_ENV_VARS)
# Anything that is not a positive integer raises a clear ValueError.
@pytest.mark.parametrize("raw", ["zero", "0", "-3", "1.5", "abc", "8a", "0x4", "1e3", "4 0"])
def test_cpu_thread_cap_requires_positive_integer(raw):
with pytest.raises(ValueError, match = "must be a positive integer"):
configure_cpu_threads({"UNSLOTH_CPU_THREADS": raw})
# env=None path uses real os.environ (production call from run.py / main.py).
def test_cpu_thread_cap_uses_os_environ_when_env_is_none(monkeypatch):
for variable in (*_THREAD_POOL_ENV_VARS, "UNSLOTH_CPU_THREADS"):
monkeypatch.delenv(variable, raising = False)
monkeypatch.setenv("UNSLOTH_CPU_THREADS", "3")
configure_cpu_threads()
for variable in _THREAD_POOL_ENV_VARS:
assert os.environ[variable] == "3"
# Calling twice must not flip any seeded value.
def test_cpu_thread_cap_idempotent(monkeypatch):
for variable in (*_THREAD_POOL_ENV_VARS, "UNSLOTH_CPU_THREADS"):
monkeypatch.delenv(variable, raising = False)
monkeypatch.setenv("UNSLOTH_CPU_THREADS", "5")
configure_cpu_threads()
snapshot = {v: os.environ.get(v) for v in _THREAD_POOL_ENV_VARS}
configure_cpu_threads()
assert {v: os.environ.get(v) for v in _THREAD_POOL_ENV_VARS} == snapshot
def _ast_line_of_configure_call(source: str) -> int:
tree = ast.parse(source)
for node in ast.walk(tree):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "configure_cpu_threads"
):
return node.lineno
raise AssertionError("configure_cpu_threads() call not found")
def _ast_line_of_platform_compat_import(source: str) -> int:
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name == "_platform_compat":
return node.lineno
raise AssertionError("_platform_compat import not found")
# AST ordering: configure_cpu_threads() must precede _platform_compat in both
# run.py and main.py. Robust to formatting / line shifts.
@pytest.mark.parametrize("entry_point", [_RUN_PY, _MAIN_PY])
def test_cpu_thread_configuration_runs_before_backend_imports(entry_point):
source = entry_point.read_text()
call_line = _ast_line_of_configure_call(source)
compat_line = _ast_line_of_platform_compat_import(source)
assert call_line < compat_line, (
f"{entry_point.name}: configure_cpu_threads() (line {call_line}) "
f"must precede import _platform_compat (line {compat_line})"
)
# Invalid env -> exit 1, one-line stderr, no traceback, gated before any
# heavy import. Parametrised over both entry points.
@pytest.mark.parametrize("entry_point", [_RUN_PY, _MAIN_PY])
def test_invalid_cpu_thread_cap_exits_without_traceback(entry_point):
env = os.environ.copy()
env["UNSLOTH_CPU_THREADS"] = "not-a-count"
result = subprocess.run(
[sys.executable, str(entry_point)],
env = env,
capture_output = True,
text = True,
)
assert result.returncode == 1
assert (
"Error: Invalid UNSLOTH_CPU_THREADS value 'not-a-count': "
"UNSLOTH_CPU_THREADS must be a positive integer"
) in result.stderr
assert "Traceback" not in result.stderr
assert "_platform_compat" not in result.stderr