mirror of
https://github.com/unslothai/unsloth.git
synced 2026-10-21 14:00:32 +00:00
Comment and docstring changes only, in test files, split out of #12968 so the test-only part can merge first.
1666 lines
82 KiB
Python
1666 lines
82 KiB
Python
"""Cross-platform parity tests between install.sh and install.ps1."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
INSTALL_SH = REPO_ROOT / "install.sh"
|
|
INSTALL_PS1 = REPO_ROOT / "install.ps1"
|
|
SETUP_PS1 = REPO_ROOT / "studio" / "setup.ps1"
|
|
STACK_PY = REPO_ROOT / "studio" / "install_python_stack.py"
|
|
|
|
|
|
def _fallback_range(lines):
|
|
"""Half-open range of the fallback branch, found by counting nested if/fi and case/esac pairs."""
|
|
start = next(i for i, line in enumerate(lines) if "GPU detection failed" in line)
|
|
depth = 0
|
|
for i in range(start, len(lines)):
|
|
stripped = lines[i].strip()
|
|
if re.match(r"^(if|case)\b", stripped):
|
|
depth += 1
|
|
elif stripped in ("fi", "esac") or stripped.startswith(("fi ", "esac ")):
|
|
if depth == 0:
|
|
return range(start, i + 1)
|
|
depth -= 1
|
|
raise AssertionError("install.sh's GPU-detection fallback branch is never closed")
|
|
|
|
|
|
def _all_indexes(haystack, needle):
|
|
"""Every occurrence, so an assertion about "each write" cannot pass on the first one."""
|
|
found, at = [], haystack.find(needle)
|
|
while at != -1:
|
|
found.append(at)
|
|
at = haystack.find(needle, at + 1)
|
|
assert found, needle
|
|
return found
|
|
|
|
|
|
class TestNoTorchBackendAutoInInstallSh:
|
|
"""install.sh primary paths must not use --torch-backend=auto (only the fallback else-branch may)."""
|
|
|
|
def test_no_torch_backend_auto_outside_fallback(self):
|
|
lines = INSTALL_SH.read_text(encoding = "utf-8").splitlines()
|
|
fallback_range = _fallback_range(lines)
|
|
|
|
matches = [
|
|
(i + 1, line)
|
|
for i, line in enumerate(lines)
|
|
if "--torch-backend=auto" in line
|
|
and not line.lstrip().startswith("#")
|
|
and i not in fallback_range
|
|
]
|
|
assert matches == [], (
|
|
f"install.sh contains --torch-backend=auto outside the fallback block at lines: "
|
|
f"{[m[0] for m in matches]}"
|
|
)
|
|
|
|
def test_the_fallback_range_reaches_the_end_of_the_branch(self):
|
|
"""The fallback range must reach its real end, not stop at the first fi inside a case arm."""
|
|
lines = INSTALL_SH.read_text(encoding = "utf-8").splitlines()
|
|
block = _fallback_range(lines)
|
|
body = "\n".join(lines[block.start : block.stop])
|
|
assert "STUDIO_LOCAL_INSTALL" in body, "the fallback block stops before its own first if"
|
|
assert body.count("--torch-backend=auto") == 2, (
|
|
"the fallback runs --torch-backend=auto once per arm; the detected block "
|
|
f"contains {body.count('--torch-backend=auto')}"
|
|
)
|
|
assert "_installed_package_version" not in body, "the fallback block ran past its `fi`"
|
|
|
|
def test_fallback_uses_torch_backend_auto(self):
|
|
"""The fallback branch should use --torch-backend=auto as recovery."""
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert (
|
|
"GPU detection failed" in text
|
|
), "install.sh should have a fallback branch for when GPU detection fails"
|
|
|
|
|
|
class TestInstallShHasGpuDetection:
|
|
"""install.sh must contain the get_torch_index_url function."""
|
|
|
|
def test_function_exists(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert (
|
|
"get_torch_index_url()" in text
|
|
), "install.sh is missing the get_torch_index_url() function"
|
|
|
|
def test_torch_index_url_assigned(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert (
|
|
"TORCH_INDEX_URL=$(get_torch_index_url)" in text
|
|
), "install.sh should assign TORCH_INDEX_URL from get_torch_index_url()"
|
|
|
|
|
|
class TestPreTuringCapParity:
|
|
"""Each of the four scripts caps cu128/cu130 to cu126 on pre-Turing GPUs, below sm_75."""
|
|
|
|
# Spelling includes the first argument so a prose mention cannot satisfy the check.
|
|
_SITES = (
|
|
(INSTALL_SH, '_cap_cuda_family_for_pre_turing "', "get_torch_index_url() {", "\n}"),
|
|
(
|
|
INSTALL_PS1,
|
|
"Get-CudaFamilyCappedForPreTuring $",
|
|
"function Get-TorchIndexUrl",
|
|
"\n }",
|
|
),
|
|
(SETUP_PS1, "Get-CudaFamilyCappedForPreTuring $", "function Get-PytorchCudaTag", "\n}"),
|
|
(
|
|
STACK_PY,
|
|
"_cap_cuda_family_for_pre_turing(",
|
|
"def _detect_cuda_torch_index_family",
|
|
"\ndef ",
|
|
),
|
|
)
|
|
|
|
def test_cu126_span_agrees_across_the_python_modules(self):
|
|
# Neither module imports the other (installer runs before deps), so pin the span here.
|
|
span = "_CU126_SM_RANGE = (50, 90)"
|
|
for path in (STACK_PY, REPO_ROOT / "studio" / "install_llama_prebuilt.py"):
|
|
assert span in path.read_text(encoding = "utf-8"), f"{path.name} lost {span}"
|
|
|
|
@pytest.mark.parametrize("path,call,start,end", _SITES)
|
|
def test_selection_function_applies_the_cap(self, path, call, start, end):
|
|
text = path.read_text(encoding = "utf-8")
|
|
assert start in text, f"{path.name} no longer defines {start!r}"
|
|
body = text.split(start, 1)[1].split(end, 1)[0]
|
|
assert call in body, f"{path.name}'s selection function never applies {call!r}"
|
|
|
|
|
|
# A rung names its family as an index suffix (/cu128) or a variable (=cu128).
|
|
_CUDA_LEAF_RE = r"""[/=]\s*["']?(cu\d+|cpu)"""
|
|
|
|
|
|
class TestCudaMappingParity:
|
|
"""CUDA version thresholds must match between install.sh and install.ps1."""
|
|
|
|
@staticmethod
|
|
def _extract_cuda_thresholds_sh(text: str) -> list[str]:
|
|
"""Extract cu* suffixes from the major/minor comparison chain in install.sh."""
|
|
in_func = False
|
|
results = []
|
|
for line in text.splitlines():
|
|
if "get_torch_index_url()" in line:
|
|
in_func = True
|
|
continue
|
|
if in_func and line.startswith("}"):
|
|
break
|
|
if in_func and ("_major" in line or "_minor" in line):
|
|
m = re.search(_CUDA_LEAF_RE, line)
|
|
if m:
|
|
results.append(m.group(1))
|
|
return results
|
|
|
|
@staticmethod
|
|
def _extract_cuda_thresholds_ps1(text: str) -> list[str]:
|
|
"""Extract cu* suffixes from the major/minor comparison chain in install.ps1."""
|
|
in_func = False
|
|
depth = 0
|
|
results = []
|
|
for line in text.splitlines():
|
|
if "function Get-TorchIndexUrl" in line:
|
|
in_func = True
|
|
depth = 1
|
|
continue
|
|
if in_func:
|
|
depth += line.count("{") - line.count("}")
|
|
if depth <= 0:
|
|
break
|
|
if "$major" in line or "$minor" in line:
|
|
m = re.search(_CUDA_LEAF_RE, line)
|
|
if m:
|
|
results.append(m.group(1))
|
|
return results
|
|
|
|
def test_same_cuda_suffixes(self):
|
|
"""Both scripts should produce the same ordered list of CUDA index suffixes."""
|
|
sh_text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
ps1_text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
|
|
sh_thresholds = self._extract_cuda_thresholds_sh(sh_text)
|
|
ps1_thresholds = self._extract_cuda_thresholds_ps1(ps1_text)
|
|
|
|
assert len(sh_thresholds) > 0, "Could not extract thresholds from install.sh"
|
|
assert len(ps1_thresholds) > 0, "Could not extract thresholds from install.ps1"
|
|
assert sh_thresholds == ps1_thresholds, (
|
|
f"CUDA mapping mismatch:\n"
|
|
f" install.sh: {sh_thresholds}\n"
|
|
f" install.ps1: {ps1_thresholds}"
|
|
)
|
|
|
|
|
|
class TestPyTorchMirrorEnvVar:
|
|
"""Both install scripts must support the UNSLOTH_PYTORCH_MIRROR env var."""
|
|
|
|
def test_install_sh_has_mirror_var(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert (
|
|
"UNSLOTH_PYTORCH_MIRROR" in text
|
|
), "install.sh should reference UNSLOTH_PYTORCH_MIRROR"
|
|
|
|
def test_install_ps1_has_mirror_var(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert (
|
|
"UNSLOTH_PYTORCH_MIRROR" in text
|
|
), "install.ps1 should reference UNSLOTH_PYTORCH_MIRROR"
|
|
|
|
|
|
class TestUvBytecodeCompileTimeout:
|
|
"""Installers should relax uv bytecode compilation timeout by default."""
|
|
|
|
@staticmethod
|
|
def _version_tuple(version: str) -> tuple[int, ...]:
|
|
return tuple(int(part) for part in version.split("."))
|
|
|
|
def test_install_sh_uses_uv_version_with_timeout_env(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
match = re.search(r'^UV_MIN_VERSION="([^"]+)"$', text, re.MULTILINE)
|
|
assert match, "install.sh should declare UV_MIN_VERSION"
|
|
assert self._version_tuple(match.group(1)) >= self._version_tuple("0.7.22")
|
|
|
|
def test_install_ps1_uses_uv_version_with_timeout_env(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
match = re.search(r'^\s*\$UvMinVersion = "([^"]+)"$', text, re.MULTILINE)
|
|
assert match, "install.ps1 should declare $UvMinVersion"
|
|
assert self._version_tuple(match.group(1)) >= self._version_tuple("0.7.22")
|
|
assert "function Test-UvVersionOk" in text
|
|
assert "if (-not (Test-UvVersionOk))" in text
|
|
|
|
def test_install_sh_preserves_timeout_override(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert (
|
|
': "${UV_COMPILE_BYTECODE_TIMEOUT:=180}"' in text
|
|
), "install.sh should default UV_COMPILE_BYTECODE_TIMEOUT without overwriting callers"
|
|
assert (
|
|
"export UV_COMPILE_BYTECODE_TIMEOUT" in text
|
|
), "install.sh should export UV_COMPILE_BYTECODE_TIMEOUT for uv subprocesses"
|
|
|
|
def test_install_ps1_preserves_timeout_override(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert (
|
|
"if (-not $env:UV_COMPILE_BYTECODE_TIMEOUT)" in text
|
|
), "install.ps1 should preserve caller UV_COMPILE_BYTECODE_TIMEOUT overrides"
|
|
assert (
|
|
'$env:UV_COMPILE_BYTECODE_TIMEOUT = "180"' in text
|
|
), "install.ps1 should default UV_COMPILE_BYTECODE_TIMEOUT"
|
|
|
|
|
|
class TestTorchIndexOverrideParity:
|
|
"""Every installer must honor UNSLOTH_TORCH_INDEX_URL / _FAMILY so a pinned wheel
|
|
index wins over GPU probing on all platforms (no asymmetric, per-OS coverage)."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
[INSTALL_SH, INSTALL_PS1, SETUP_PS1, STACK_PY],
|
|
ids = ["install.sh", "install.ps1", "setup.ps1", "install_python_stack.py"],
|
|
)
|
|
def test_installer_reads_override_env(self, path):
|
|
text = path.read_text(encoding = "utf-8")
|
|
for var in ("UNSLOTH_TORCH_INDEX_URL", "UNSLOTH_TORCH_INDEX_FAMILY"):
|
|
assert var in text, f"{path.name} does not honor {var}"
|
|
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
[INSTALL_PS1, SETUP_PS1],
|
|
ids = ["install.ps1", "setup.ps1"],
|
|
)
|
|
def test_amd_reroute_guarded_when_pinned(self, path):
|
|
# An explicit cpu/cu*/rocm pin on an AMD host must skip the ROCm reroute.
|
|
text = path.read_text(encoding = "utf-8")
|
|
assert (
|
|
"TorchIndexPinned" in text
|
|
), f"{path.name} should gate the AMD ROCm reroute on a pinned-index flag"
|
|
|
|
def test_cuda_pin_overrides_cvd_hide_gate(self):
|
|
# A pinned cu* index skips GPU probing, so repair must clear the CUDA_VISIBLE_DEVICES gate.
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
m = re.search(r"def _ensure_cuda_torch\(.*?(?=\ndef )", text, re.DOTALL)
|
|
assert m, "could not locate _ensure_cuda_torch"
|
|
body = m.group(0)
|
|
assert "_cuda_pinned" in body, (
|
|
"_ensure_cuda_torch should compute a CUDA-pin flag so the pin can "
|
|
"override the CVD hide gate"
|
|
)
|
|
assert re.search(
|
|
r"if not _cuda_pinned and _cvd is not None", body
|
|
), "the CVD hide gate must be bypassed when a CUDA index is pinned"
|
|
|
|
def test_cpu_repair_pins_supported_torch_range(self):
|
|
# The /cpu index serves torch 2.11+, so a bare trio could resolve out of range.
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
m = re.search(r"def _ensure_cpu_torch\(\).*?(?=\ndef )", text, re.DOTALL)
|
|
assert m, "could not locate _ensure_cpu_torch"
|
|
body = m.group(0)
|
|
assert "_CPU_TORCH_PKG_SPEC" in body, (
|
|
"_ensure_cpu_torch should install the bounded _CPU_TORCH_PKG_SPEC, "
|
|
"not a bare torch/torchvision/torchaudio trio"
|
|
)
|
|
|
|
def test_setup_ps1_stale_check_gates_rocm_on_supported_arch(self):
|
|
# Expecting rocm for an unmapped arch would mark a good CPU venv stale.
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
assert "_rocmWheelArches" in text, (
|
|
"setup.ps1 stale check should restrict the ROCm expected-tag to the "
|
|
"supported gfx wheel arches"
|
|
)
|
|
|
|
|
|
class TestGfx211AllowlistParity:
|
|
"""Every installer's gfx 2.11-floor leaf set must equal EXPECTED; order and spacing do not matter."""
|
|
|
|
EXPECTED = {"gfx120x-all", "gfx1151", "gfx1150", "gfx1152", "gfx103x-all", "gfx110x-all"}
|
|
|
|
@staticmethod
|
|
def _leaves(blob: str) -> set[str]:
|
|
"""The gfx leaves named in an allowlist literal, quoting-agnostic."""
|
|
return set(re.findall(r"gfx[0-9a-z-]+", blob.lower()))
|
|
|
|
def test_install_sh_allowlist(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8").lower()
|
|
m = re.search(r"^\s*(rocm7\.2\|[a-z0-9|.\-]*)\)", text, re.MULTILINE)
|
|
assert m, "install.sh gfx-2.11 allowlist case not found / changed"
|
|
assert self._leaves(m.group(1)) == self.EXPECTED, (
|
|
f"install.sh gfx-2.11 allowlist is {sorted(self._leaves(m.group(1)))}, "
|
|
f"expected {sorted(self.EXPECTED)}"
|
|
)
|
|
|
|
def test_install_ps1_allowlist(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8").lower()
|
|
m = re.search(r"\$_pingfx211\s*=\s*@\(([^)]*)\)", text)
|
|
assert m, "install.ps1 $_pinGfx211 allowlist not found / changed"
|
|
assert self._leaves(m.group(1)) == self.EXPECTED, (
|
|
f"install.ps1 $_pinGfx211 is {sorted(self._leaves(m.group(1)))}, "
|
|
f"expected {sorted(self.EXPECTED)}"
|
|
)
|
|
|
|
def test_setup_ps1_defines_single_allowlist_helper(self):
|
|
# One shared allowlist so the stale check and install spec cannot disagree.
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
assert (
|
|
"function Test-RocmGfx211Leaf" in text
|
|
), "setup.ps1 should define a single Test-RocmGfx211Leaf allowlist helper"
|
|
m = re.search(r"function test-rocmgfx211leaf[\s\S]{0,400}?@\(([^)]*)\)", text.lower())
|
|
assert m, "Test-RocmGfx211Leaf should hold the gfx-2.11 allowlist"
|
|
assert self._leaves(m.group(1)) == self.EXPECTED, (
|
|
f"Test-RocmGfx211Leaf holds {sorted(self._leaves(m.group(1)))}, "
|
|
f"expected {sorted(self.EXPECTED)}"
|
|
)
|
|
assert "$_pinGfx211 = Test-RocmGfx211Leaf" in text, (
|
|
"setup.ps1 install-spec path should reuse Test-RocmGfx211Leaf, not "
|
|
"re-hardcode the allowlist (they must not diverge)"
|
|
)
|
|
|
|
def test_stack_py_allowlist(self):
|
|
text = STACK_PY.read_text(encoding = "utf-8").lower()
|
|
m = re.search(r"_rocm_gfx_torch211_leaves[^=]*=\s*frozenset\(\s*\{([^}]*)\}", text)
|
|
assert m, "install_python_stack.py _ROCM_GFX_TORCH211_LEAVES not found / changed"
|
|
assert self._leaves(m.group(1)) == self.EXPECTED, (
|
|
f"_ROCM_GFX_TORCH211_LEAVES is {sorted(self._leaves(m.group(1)))}, "
|
|
f"expected {sorted(self.EXPECTED)}"
|
|
)
|
|
|
|
|
|
class TestCudaLeafDigitParity:
|
|
"""A leaf is CUDA only when it is cu+digits; a bare cu* glob wrongly catches /custom and /current."""
|
|
|
|
def test_stack_py_requires_cu_digit(self):
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
# Exact cu+digits: a custom leaf like cu128-private takes the verbatim path.
|
|
assert re.search(
|
|
r'r"cu\[0-9\]\+"', text
|
|
), "install_python_stack.py _is_cuda_family_leaf must fullmatch cu[0-9]+"
|
|
|
|
def test_setup_ps1_requires_cu_digit(self):
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
# cu128-private must not classify as CUDA or the venv rebuilds on every update.
|
|
assert re.search(
|
|
r"'\^cu\[0-9\]\+\$'", text
|
|
), "setup.ps1 Test-CudaFamilyLeaf must match ^cu[0-9]+$, not a cu* prefix"
|
|
assert (
|
|
"Test-CudaFamilyLeaf $_pinLeaf" in text
|
|
), "setup.ps1 stale check should classify CUDA via Test-CudaFamilyLeaf"
|
|
|
|
def test_install_ps1_requires_cu_digit_in_gpu_branch(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert re.search(
|
|
r"'\^cu\[0-9\]'", text
|
|
), "install.ps1 Get-TauriGpuBranch must require a digit after cu"
|
|
|
|
def test_install_sh_requires_cu_digit_in_gpu_branch(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert re.search(
|
|
r"cu\[0-9\]\*\)\s*echo \"cuda\"", text
|
|
), "install.sh _tauri_gpu_branch cuda case must be cu[0-9]*, not cu*"
|
|
|
|
def test_install_sh_backend_export_requires_cu_digit(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
# A bare *) -> cuda would mis-brand /current, /custom pins and skip ROCm repair.
|
|
assert re.search(
|
|
r'cu\[0-9\]\*\)\s*export UNSLOTH_TORCH_BACKEND="cuda"', text
|
|
), "install.sh backend export must brand cuda only on cu[0-9]*"
|
|
assert re.search(
|
|
r"\*\)\s*unset UNSLOTH_TORCH_BACKEND", text
|
|
), "install.sh backend export must unset (not force cuda) on an unknown leaf"
|
|
|
|
def test_install_sh_lowercases_backend_leaf(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
# Lowercased so the canonical gfx120X-all (capital X) matches.
|
|
assert re.search(
|
|
r"_torch_index_leaf=\$\(printf '%s' \"\$_torch_index_leaf\" \| tr '\[:upper:\]' '\[:lower:\]'\)",
|
|
text,
|
|
), "install.sh must lowercase _torch_index_leaf before the gfx/rocm/cu case matches"
|
|
|
|
|
|
class TestKnown211SetParity:
|
|
"""The KNOWN-2.11 rocm/gfx set must be identical across all four installers:
|
|
exactly {rocm7.2} plus TestGfx211AllowlistParity.EXPECTED.
|
|
rocm7.3 / torch 2.12 do not exist, so no side may floor them speculatively."""
|
|
|
|
def test_install_sh_known_211_leaf_is_rocm72_and_gfx_allowlist(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
# Same set as TestGfx211AllowlistParity.EXPECTED, so the rocm half cannot drift.
|
|
m = re.search(r"^\s*(rocm7\.2\|[a-zA-Z0-9|.\-]*)\)", text, re.MULTILINE)
|
|
assert m, "install.sh 2.11 floor case (rocm7.2|gfx...) not found / changed"
|
|
alternatives = set(m.group(1).lower().split("|"))
|
|
assert alternatives == {"rocm7.2"} | TestGfx211AllowlistParity.EXPECTED, (
|
|
f"install.sh 2.11 floor is {sorted(alternatives)}, expected "
|
|
f"{sorted({'rocm7.2'} | TestGfx211AllowlistParity.EXPECTED)}"
|
|
)
|
|
assert "rocm7.3" not in text, "install.sh must not reference a non-existent rocm7.3"
|
|
|
|
def test_python_known_211_versions_is_only_rocm72(self):
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
assert "_ROCM_KNOWN_TORCH211_VERSIONS" in text
|
|
m = re.search(r"_ROCM_KNOWN_TORCH211_VERSIONS[^=]*=\s*frozenset\(\{([^}]*)\}\)", text)
|
|
assert m is not None, "install_python_stack.py must define _ROCM_KNOWN_TORCH211_VERSIONS"
|
|
assert "(7, 2)" in m.group(1)
|
|
assert "7, 3" not in m.group(1) and "7, 1" not in m.group(1)
|
|
|
|
def test_setup_ps1_known_211_helper_is_only_rocm72(self):
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
assert "Test-RocmKnown211Version" in text
|
|
assert re.search(
|
|
r"Test-RocmKnown211Version[\s\S]{0,400}\$Major -eq 7 -and \$Minor -eq 2", text
|
|
), "setup.ps1 Test-RocmKnown211Version must accept only rocm7.2"
|
|
|
|
def test_install_ps1_pin_floor_is_only_rocm72(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
# The pinned ROCm floor must be Major -eq 7 -and Minor -eq 2, not the speculative >= 2.
|
|
assert re.search(
|
|
r"\$_pinRocm211 = \(\[int\]\$Matches\[1\] -eq 7 -and \[int\]\$Matches\[2\] -eq 2\)",
|
|
text,
|
|
), "install.ps1 pinned-ROCm floor must be rocm7.2 only (no speculative >= 2)"
|
|
|
|
def test_ps1_pin_floor_gate_is_anchored(self):
|
|
"""The PS1 floor gate must anchor its rocm match, or rocm7.2-private takes the 2.11-floor branch."""
|
|
for path, label in ((INSTALL_PS1, "install.ps1"), (SETUP_PS1, "setup.ps1")):
|
|
text = path.read_text(encoding = "utf-8")
|
|
assert "-match '^rocm(\\d+)\\.(\\d+)$'" in text, (
|
|
f"{label} floor gate must anchor the rocm match (^rocm(\\d+)\\.(\\d+)$) so a "
|
|
"suffixed custom leaf is not floored/routed as rocm7.2"
|
|
)
|
|
assert (
|
|
"-match '^rocm(\\d+)\\.(\\d+)'\n" not in text
|
|
), f"{label} floor gate must not use the unanchored ^rocm(\\d+)\\.(\\d+) prefix"
|
|
|
|
def test_install_ps1_bounds_unknown_leaf_pinned_torch(self):
|
|
"""Bound the torch trio below 2.12 on every index: torchaudio 2.11 dropped its exact torch pin."""
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert (
|
|
'$_pinTorchSpec = "torch>=2.4,<2.12.0"' in text
|
|
), "install.ps1 default install must use the torch 2.11 line (<2.12.0)"
|
|
assert (
|
|
'$_pinVisionSpec = "torchvision>=0.19,<0.27.0"' in text
|
|
), "install.ps1 must pair torchvision <0.27.0 with torch <2.12"
|
|
assert (
|
|
'$_pinAudioSpec = "torchaudio>=2.4,<2.12.0"' in text
|
|
), "install.ps1 must pair torchaudio <2.12.0 with torch <2.12"
|
|
assert (
|
|
"$_pinCuLeaf" not in text
|
|
), "install.ps1 must bound companions on every index (no cu-family exemption)"
|
|
# Scoped to the default trio: Get-XpuTorchSpecs keeps a curated sub-2.11 cap.
|
|
for _stale in (
|
|
'$_pinTorchSpec = "torch>=2.4,<2.11.0"',
|
|
'$_pinVisionSpec = "torchvision>=0.19,<0.26.0"',
|
|
'$_pinAudioSpec = "torchaudio>=2.4,<2.11.0"',
|
|
'$_torchSpecs = @("torch>=2.4,<2.11.0"',
|
|
):
|
|
assert (
|
|
_stale not in text
|
|
), f"install.ps1 must not retain the <2.11.0 default torch line: {_stale}"
|
|
assert (
|
|
"$_torchSpecs = @($_pinTorchSpec, $_pinVisionSpec, $_pinAudioSpec)" in text
|
|
), "install.ps1 pinned install must build the bounded trio spec list"
|
|
assert (
|
|
"@_torchSpecs --default-index $TorchIndexUrl" in text
|
|
), "install.ps1 pinned install must pass the bounded trio specs to uv"
|
|
|
|
def test_gfx_allowlist_matches_across_installers(self):
|
|
gfx = ("gfx120x-all", "gfx1151", "gfx1150")
|
|
for path, label in (
|
|
(INSTALL_SH, "install.sh"),
|
|
(INSTALL_PS1, "install.ps1"),
|
|
(SETUP_PS1, "setup.ps1"),
|
|
(STACK_PY, "install_python_stack.py"),
|
|
):
|
|
low = path.read_text(encoding = "utf-8").lower()
|
|
for g in gfx:
|
|
assert g in low, f"{label} missing gfx 2.11 allowlist member {g}"
|
|
|
|
|
|
class TestPinnedRocmLeafDigitParity:
|
|
"""A pinned index is a ROCm family only on an exact rocm+digits or gfx* leaf, not a prefix match."""
|
|
|
|
def test_install_ps1_pinned_reroute_requires_rocm_digit(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
# Anchored so rocm7.2-private / rocm-current fall through to --default-index.
|
|
assert "-match '^rocm[0-9]+(\\.[0-9]+)?$'" in text, (
|
|
"install.ps1 pinned-index reroute must anchor the rocm match "
|
|
"(^rocm[0-9]+(\\.[0-9]+)?$), not a bare -like 'rocm*' or an unanchored ^rocm\\d"
|
|
)
|
|
assert (
|
|
"-like 'rocm*'" not in text
|
|
), "install.ps1 must not route a pinned index on a bare -like 'rocm*' glob"
|
|
assert (
|
|
"-match '^rocm\\d'" not in text
|
|
), "install.ps1 must not route a pinned index on an unanchored -match '^rocm\\d'"
|
|
|
|
def test_setup_ps1_pinned_reroute_requires_rocm_digit(self):
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
# Test-PipRocmFamilyLeaf anchors the rocm match for every family decision.
|
|
assert (
|
|
"function Test-PipRocmFamilyLeaf" in text
|
|
), "setup.ps1 must define Test-PipRocmFamilyLeaf (the exact rocm/gfx family gate)"
|
|
assert "'^rocm[0-9]+(\\.[0-9]+)?$'" in text, (
|
|
"setup.ps1 Test-PipRocmFamilyLeaf must anchor the rocm match "
|
|
"(^rocm[0-9]+(\\.[0-9]+)?$) so rocm7.2-private / rocm-current stay verbatim"
|
|
)
|
|
pinned_block = text[text.find("$_pinGfx211 = Test-RocmGfx211Leaf") :][:2000]
|
|
assert (
|
|
"-like 'rocm*'" not in pinned_block
|
|
), "setup.ps1 pinned reroute must not route on a bare -like 'rocm*' glob"
|
|
|
|
def test_install_sh_repairable_requires_rocm_digit(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert (
|
|
"_is_pip_rocm_family_leaf" in text
|
|
), "install.sh must define/use _is_pip_rocm_family_leaf for the exact rocm gate"
|
|
# gfx needs a digit: gfx-private / gfxfoo are custom verbatim pins.
|
|
assert re.search(
|
|
r'case "\$1" in\n\s*gfx\[0-9\]\*\) return 0', text
|
|
), "install.sh _is_pip_rocm_family_leaf must treat only gfx<digit>* as a family"
|
|
assert not re.search(
|
|
r'case "\$1" in\n\s*gfx\*\) return 0', text
|
|
), "install.sh _is_pip_rocm_family_leaf must not family-match a bare gfx* glob"
|
|
|
|
def test_stack_py_pip_rocm_family_requires_digit(self):
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
assert re.search(
|
|
r'fullmatch\(r"rocm\\d\+\(\?:\\\.\\d\+\)\?", leaf\)', text
|
|
), "install_python_stack.py _is_pip_rocm_family_leaf must fullmatch rocm\\d+(?:\\.\\d+)?"
|
|
assert (
|
|
're.match(r"^rocm\\d"' not in text
|
|
), "install_python_stack.py must not gate a family on an unanchored re.match(^rocm\\d)"
|
|
|
|
def test_install_sh_rocm_side_effects_digit_gated(self):
|
|
"""ROCm side effects must fire only on an exact ROCm family leaf, not a rocm prefix or glob."""
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert (
|
|
'if _is_pip_rocm_family_leaf "$_torch_index_leaf"; then\n _torch_index_is_rocm_family=true'
|
|
in text
|
|
), "install.sh must set _torch_index_is_rocm_family from the exact-match helper"
|
|
assert (
|
|
'[ "$_torch_index_is_rocm_family" = true ]' in text
|
|
), "install.sh ROCm bnb/repair hooks must gate on _torch_index_is_rocm_family"
|
|
assert (
|
|
"*/rocm*|*/gfx*)\n _install_bnb_rocm" not in text
|
|
), "install.sh must not gate _install_bnb_rocm on a bare */rocm* whole-URL glob"
|
|
|
|
|
|
class TestPinnedIndexClearsUvEnvParity:
|
|
"""Pinned installs must clear the UV_INDEX env vars, or an inherited uv mirror wins over the pin."""
|
|
|
|
UV_VARS = ("UV_DEFAULT_INDEX", "UV_INDEX_URL", "UV_INDEX", "UV_EXTRA_INDEX_URL")
|
|
|
|
def test_install_sh_clears_uv_index_vars(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert (
|
|
"env -u UV_DEFAULT_INDEX -u UV_INDEX_URL -u UV_INDEX -u UV_EXTRA_INDEX_URL" in text
|
|
), "install.sh run_install_cmd must clear the uv index vars for --default-index installs"
|
|
|
|
def test_install_ps1_clears_uv_index_vars(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
for var in self.UV_VARS:
|
|
assert var in text, f"install.ps1 must clear {var} for pinned installs"
|
|
|
|
def test_setup_ps1_clears_uv_index_vars(self):
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
for var in self.UV_VARS:
|
|
assert var in text, f"setup.ps1 must clear {var} for pinned installs"
|
|
|
|
def test_stack_py_clears_uv_index_vars(self):
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
assert "_install_env_for_cmd" in text, (
|
|
"install_python_stack.py must scrub inherited uv index vars for pinned "
|
|
"installs via _install_env_for_cmd (parity with install.sh #6898)"
|
|
)
|
|
for var in self.UV_VARS:
|
|
assert var in text, f"install_python_stack.py must clear {var} for pinned installs"
|
|
|
|
def test_all_installers_clear_uv_torch_backend(self):
|
|
"""uv's torch backend redirects torch resolution to its own per-backend
|
|
index even against an explicit pin, so every installer's pinned-install
|
|
scrub must clear UV_TORCH_BACKEND too."""
|
|
sh = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert "-u UV_TORCH_BACKEND" in sh, "install.sh pinned scrub must clear UV_TORCH_BACKEND"
|
|
for path in (INSTALL_PS1, SETUP_PS1):
|
|
text = path.read_text(encoding = "utf-8")
|
|
assert (
|
|
"'UV_TORCH_BACKEND'" in text
|
|
), f"{path.name} pinned scrub must clear UV_TORCH_BACKEND"
|
|
stack = STACK_PY.read_text(encoding = "utf-8")
|
|
assert (
|
|
'"UV_TORCH_BACKEND",' in stack
|
|
), "install_python_stack.py strip tuple must include UV_TORCH_BACKEND"
|
|
|
|
def test_stack_py_strips_pip_extra_index_for_pip_fallback(self):
|
|
"""The pip fallback honours PIP_EXTRA_INDEX_URL (pip adds it IN ADDITION
|
|
to --index-url), so the pinned-command scrub must strip it."""
|
|
stack = STACK_PY.read_text(encoding = "utf-8")
|
|
assert (
|
|
'"PIP_EXTRA_INDEX_URL",' in stack
|
|
), "install_python_stack.py strip tuple must include PIP_EXTRA_INDEX_URL"
|
|
|
|
def test_all_installers_scrub_find_links(self):
|
|
"""uv's --find-links (env UV_FIND_LINKS) adds candidate locations that can
|
|
satisfy torch off a pinned index; every pinned-install scrub must clear it."""
|
|
sh = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert "-u UV_FIND_LINKS" in sh
|
|
for path in (INSTALL_PS1, SETUP_PS1):
|
|
assert "'UV_FIND_LINKS'" in path.read_text(encoding = "utf-8"), path.name
|
|
stack = STACK_PY.read_text(encoding = "utf-8")
|
|
assert '"UV_FIND_LINKS",' in stack and '"PIP_FIND_LINKS",' in stack
|
|
|
|
def test_setup_ps1_scrub_covers_pip_fallback(self):
|
|
"""setup.ps1's Fast-Install must keep the scrub active through the pip
|
|
fallback (pip honours PIP_EXTRA_INDEX_URL / PIP_FIND_LINKS in addition to
|
|
--index-url); restoring the vars before the fallback reopens the hole."""
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
fi = text[text.find("function Fast-Install") :][:2500]
|
|
assert "'PIP_EXTRA_INDEX_URL'" in fi and "'PIP_FIND_LINKS'" in fi
|
|
assert fi.find("python -m pip install") < fi.find(
|
|
"finally"
|
|
), "pip fallback must run before the scrub is restored"
|
|
|
|
def test_windows_installers_probe_uv_before_replacing_an_incumbent(self):
|
|
"""Probe the new uv.exe before touching the destination; restore the incumbent if it will
|
|
not run."""
|
|
for path, probe in (
|
|
(INSTALL_PS1, "Get-UvExecutableVerdict"),
|
|
(SETUP_PS1, "Get-SetupUvExecutableVerdict"),
|
|
):
|
|
text = path.read_text(encoding = "utf-8")
|
|
assert f"function {probe}" in text, f"{path.name} must define {probe}"
|
|
# An unbounded wait on a freshly downloaded binary hangs unattended installs.
|
|
assert "WaitForExit(20000)" in text, f"{path.name}'s uv probe must bound its wait"
|
|
probe_at = text.index(f"({probe} -Path $stagedUv)")
|
|
# Tri-state: a launch error or timeout is no verdict (breaks in containers/arm64);
|
|
# only a non-zero exit from the binary may block the install.
|
|
body = text.split(f"function {probe}", 1)[1].split("\n }\n", 1)[0]
|
|
# WaitForExit(ms) can return before the exit code is cached; empty is no verdict.
|
|
assert (
|
|
"try { $proc.WaitForExit() } catch {}" in body
|
|
), f"{path.name} must settle the exit code before reading it"
|
|
assert (
|
|
'$null -eq $code -or "$code" -eq ""' in body
|
|
), f"{path.name} must treat a missing exit code as inconclusive"
|
|
assert (
|
|
body.count('return "unknown"') == 3
|
|
), f"{path.name}: a launch failure and a timeout must both be inconclusive"
|
|
assert (
|
|
'return "failed"' in body and 'return "ok"' in body
|
|
), f"{path.name}'s probe must report a real answer as well"
|
|
assert (
|
|
f'({probe} -Path $stagedUv) -eq "failed"' in text
|
|
), f"{path.name} must gate only on a failed verdict"
|
|
copy_at = text.index("Copy-Item -LiteralPath $src -Destination $dst -Force")
|
|
assert probe_at < copy_at, (
|
|
f"{path.name} must probe the extracted uv.exe before copying over the "
|
|
"destination"
|
|
)
|
|
# A locked or ACL-denied destination must fail so the caller's fallback runs.
|
|
assert (
|
|
"Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction Stop" in text
|
|
), f"{path.name} must copy each executable under -ErrorAction Stop"
|
|
|
|
def test_all_installers_disable_uv_config_for_pinned_installs(self):
|
|
"""Pinned installs must set UV_NO_CONFIG=1 and drop UV_CONFIG_FILE, or discovered uv config wins."""
|
|
sh = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert "-u UV_CONFIG_FILE UV_NO_CONFIG=1" in sh, (
|
|
"install.sh run_install_cmd must set UV_NO_CONFIG=1 and drop "
|
|
"UV_CONFIG_FILE for --default-index installs"
|
|
)
|
|
for path in (INSTALL_PS1, SETUP_PS1):
|
|
text = path.read_text(encoding = "utf-8")
|
|
assert "'UV_CONFIG_FILE'" in text, f"{path.name} must drop UV_CONFIG_FILE"
|
|
assert (
|
|
"$env:UV_NO_CONFIG = '1'" in text
|
|
), f"{path.name} must set UV_NO_CONFIG=1 for pinned installs"
|
|
stack = STACK_PY.read_text(encoding = "utf-8")
|
|
assert (
|
|
'"UV_CONFIG_FILE",' in stack
|
|
), "install_python_stack.py strip tuple must include UV_CONFIG_FILE"
|
|
assert (
|
|
'env["UV_NO_CONFIG"] = "1"' in stack
|
|
), "_install_env_for_cmd must set UV_NO_CONFIG=1 for pinned installs"
|
|
|
|
def test_pip_fallbacks_disable_pip_config_files(self):
|
|
"""Pinned pip fallbacks must set PIP_CONFIG_FILE to os.devnull, or pip config still adds indexes."""
|
|
stack = STACK_PY.read_text(encoding = "utf-8")
|
|
assert 'env["PIP_CONFIG_FILE"] = os.devnull' in stack, (
|
|
"_install_env_for_cmd must point PIP_CONFIG_FILE at os.devnull for "
|
|
"pinned installs (pip fallback isolation)"
|
|
)
|
|
# devnull is all or nothing, so transport and only-binary are restored key by key.
|
|
assert "_pinned_pip_config_overrides()" in stack, (
|
|
"the pinned scrub must re-assert the operator's transport and binary policy "
|
|
"that PIP_CONFIG_FILE=devnull removes"
|
|
)
|
|
setup = SETUP_PS1.read_text(encoding = "utf-8")
|
|
assert "$env:PIP_CONFIG_FILE = 'nul'" in setup, (
|
|
"setup.ps1 Fast-Install pinned scrub must point PIP_CONFIG_FILE at nul "
|
|
"(Windows devnull) so the pip fallback ignores user/site pip config"
|
|
)
|
|
assert (
|
|
"'PIP_CONFIG_FILE'" in setup
|
|
), "setup.ps1 must save/restore PIP_CONFIG_FILE around the pinned scrub"
|
|
|
|
def test_setup_ps1_bounds_unknown_leaf_pinned_torch(self):
|
|
"""setup.ps1's custom-leaf branch must bound all three torch packages, or a mirror can lift
|
|
the venv."""
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
# Custom-leaf branch bounds torch and both companions, like the other installers.
|
|
for spec in (
|
|
'$cudaTorchSpec = "torch>=2.4,<2.12.0"',
|
|
'$cudaVisionSpec = "torchvision>=0.19,<0.27.0"',
|
|
'$cudaAudioSpec = "torchaudio>=2.4,<2.12.0"',
|
|
):
|
|
assert spec in text, f"setup.ps1 must bound the custom-leaf trio: {spec}"
|
|
assert (
|
|
"if ($TorchIndexPinned -and -not (Test-CudaFamilyLeaf $CuTag)) {" in text
|
|
), "the custom-leaf trio bounds must be gated on a pinned non-cu-family leaf"
|
|
assert (
|
|
"$_cudaTrio = @($cudaTorchSpec, $cudaVisionSpec, $cudaAudioSpec)" in text
|
|
), "setup.ps1's CUDA branch must build the trio from the bounded spec variables"
|
|
assert (
|
|
"Fast-Install @_cudaTrio @cudaForce" in text
|
|
), "setup.ps1's CUDA branch must install the trio it built"
|
|
|
|
def test_setup_ps1_bounds_pinned_cpu_torch(self):
|
|
"""Pinned CPU installs in setup.ps1 must bound the trio, since the /cpu index serves newer torch."""
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
for spec in (
|
|
'$cpuTorchSpec = "torch>=2.4,<2.12.0"',
|
|
'$cpuVisionSpec = "torchvision>=0.19,<0.27.0"',
|
|
'$cpuAudioSpec = "torchaudio>=2.4,<2.12.0"',
|
|
):
|
|
assert spec in text, f"setup.ps1 must bound the pinned CPU trio: {spec}"
|
|
assert (
|
|
"if ($TorchIndexPinned) {" in text
|
|
), "the CPU trio bounds must be gated on an explicit pin"
|
|
assert (
|
|
"$_torchTrio = @($cpuTorchSpec, $cpuVisionSpec, $cpuAudioSpec)" in text
|
|
), "setup.ps1's CPU branch must build the trio from the spec variables"
|
|
assert (
|
|
"Fast-Install @_torchTrio @cpuForce" in text
|
|
), "setup.ps1's CPU branch must install the trio it built"
|
|
stack = STACK_PY.read_text(encoding = "utf-8")
|
|
spec_block = re.search(r"_CUDA_TORCH_PKG_SPEC[^(]*\(\s*(.*?)\)", stack, re.DOTALL)
|
|
assert spec_block and '"torch>=2.4,<2.12.0"' in spec_block.group(1), (
|
|
"_CPU_TORCH_PKG_SPEC (via _CUDA_TORCH_PKG_SPEC) must keep the torch<2.12 "
|
|
"ceiling the setup.ps1 pinned CPU branch mirrors"
|
|
)
|
|
|
|
def test_setup_ps1_stale_check_requires_rocm_digit(self):
|
|
"""The stale-venv check must use the same EXACT rocm/gfx gate as the install
|
|
selection (Test-PipRocmFamilyLeaf), or a custom rocm-* / suffixed rocm7.2-private
|
|
leaf is stale-compared as a family and force-reinstalls on every studio update."""
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
anchor = text.find("$_pinLeaf = Get-TorchIndexLeaf $_pinnedIdx")
|
|
assert anchor >= 0, "setup.ps1 stale check must classify the pinned leaf"
|
|
stale = text[anchor:][:2500]
|
|
assert (
|
|
"Test-PipRocmFamilyLeaf" in stale
|
|
), "setup.ps1 stale check must gate rocm leaves via the exact Test-PipRocmFamilyLeaf"
|
|
assert (
|
|
stale.count("-like 'rocm*'") == 0
|
|
), "setup.ps1 stale check must not use a bare -like 'rocm*' glob"
|
|
assert (
|
|
"-match '^rocm\\d'" not in stale
|
|
), "setup.ps1 stale check must not use an unanchored -match '^rocm\\d'"
|
|
|
|
|
|
class TestIndexPathSlashTrimParity:
|
|
"""Trim trailing slashes from the path only, so a query token ending in / is not corrupted."""
|
|
|
|
def test_helper_defined_in_all_installers(self):
|
|
assert "def _trim_index_path_slashes(" in STACK_PY.read_text(encoding = "utf-8")
|
|
assert "_trim_index_path_slashes()" in INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert "function Trim-IndexPathSlashes" in INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert "function Trim-IndexPathSlashes" in SETUP_PS1.read_text(encoding = "utf-8")
|
|
|
|
def test_helper_wired_into_override_in_all_installers(self):
|
|
assert "_trim_index_path_slashes(url)" in STACK_PY.read_text(encoding = "utf-8")
|
|
assert '_url=$(_trim_index_path_slashes "$_url")' in INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert "Trim-IndexPathSlashes $env:UNSLOTH_TORCH_INDEX_URL" in INSTALL_PS1.read_text(
|
|
encoding = "utf-8"
|
|
)
|
|
assert "Trim-IndexPathSlashes $env:UNSLOTH_TORCH_INDEX_URL" in SETUP_PS1.read_text(
|
|
encoding = "utf-8"
|
|
)
|
|
|
|
|
|
class TestInstallOutputRedactionParity:
|
|
"""uv/pip failure text embeds the failing --index-url verbatim, so a captured install
|
|
log dumped on error can leak a user:token@ or ?token= secret. Every installer must
|
|
DEFINE a redaction helper and WIRE it into the captured-output print path."""
|
|
|
|
def test_helper_defined_in_all_installers(self):
|
|
assert "def _redact_install_output(" in STACK_PY.read_text(encoding = "utf-8")
|
|
assert "_redact_install_output()" in INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert "function Redact-InstallOutput" in INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert "function Redact-InstallOutput" in SETUP_PS1.read_text(encoding = "utf-8")
|
|
|
|
def test_helper_wired_into_failure_print(self):
|
|
assert '_redact_install_output "$_log"' in INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert (
|
|
"Write-StudioLine (Redact-InstallOutput $output) -ForegroundColor Red"
|
|
in INSTALL_PS1.read_text(encoding = "utf-8")
|
|
)
|
|
assert (
|
|
"Write-StudioLine (Redact-InstallOutput $output) -ForegroundColor Red"
|
|
in SETUP_PS1.read_text(encoding = "utf-8")
|
|
)
|
|
assert "_redact_install_output(" in STACK_PY.read_text(encoding = "utf-8")
|
|
|
|
|
|
class TestPipNoIndexScrubParity:
|
|
"""Pinned pip fallbacks must scrub PIP_NO_INDEX and PIP_INDEX_URL, which defeat the pinned index."""
|
|
|
|
def test_python_scrubs_pip_no_index_and_pip_index_url(self):
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
assert '"PIP_NO_INDEX"' in text
|
|
assert '"PIP_INDEX_URL"' in text
|
|
|
|
def test_setup_ps1_scrubs_pip_no_index_and_pip_index_url(self):
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
assert "'PIP_NO_INDEX'" in text
|
|
assert "'PIP_INDEX_URL'" in text
|
|
|
|
|
|
class TestNoTorchPersistenceParity:
|
|
"""UNSLOTH_NO_TORCH is not exported on update, so the install manifest must record no-torch mode."""
|
|
|
|
def test_the_stack_records_the_mode_it_installed(self):
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
assert "no_torch = NO_TORCH" in text
|
|
assert "install_manifest.recorded_no_torch()" in text
|
|
# Marker written before the dependency pass so a killed pass still records the mode.
|
|
assert text.index("install_manifest.set_no_torch_marker(NO_TORCH)") > text.index(
|
|
"if install_manifest.remove_manifest():"
|
|
)
|
|
removed_at = text.index("if install_manifest.remove_manifest():")
|
|
consumed_at = text.index("install_manifest.consume_previous_manifest()", removed_at)
|
|
assert consumed_at < text.index("install_manifest.set_no_torch_marker(NO_TORCH)")
|
|
|
|
def test_both_sides_use_the_same_marker_filename(self):
|
|
manifest = (REPO_ROOT / "studio" / "install_manifest.py").read_text(encoding = "utf-8")
|
|
assert 'NO_TORCH_MARKER = ".unsloth-no-torch"' in manifest
|
|
assert '$NoTorchMarker = ".unsloth-no-torch"' in SETUP_PS1.read_text(encoding = "utf-8")
|
|
|
|
def test_setup_ps1_recovers_the_mode_when_no_env_var_is_exported(self):
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
assert "function Get-PersistedNoTorch" in text
|
|
assert "function Set-PersistedNoTorch" in text
|
|
# setup.ps1 drops the manifest first, so the answer is passed via the environment.
|
|
assert text.index("Get-PersistedNoTorch -VenvPath $VenvDir") < text.index(
|
|
'$env:UNSLOTH_NO_TORCH = if ($NoTorchMode) { "true" } else { "false" }'
|
|
)
|
|
|
|
def test_both_sides_accept_the_same_spellings(self):
|
|
# Consumers must accept the same 1|true|yes|on set as the installers.
|
|
assert "'^\\s*(?i:true|1|yes|on)\\s*$'" in SETUP_PS1.read_text(encoding = "utf-8")
|
|
manifest = (REPO_ROOT / "studio" / "install_manifest.py").read_text(encoding = "utf-8")
|
|
assert 'NO_TORCH_TRUTHY: Tuple[str, ...] = ("1", "true", "yes", "on")' in manifest
|
|
assert "install_manifest.NO_TORCH_TRUTHY" in STACK_PY.read_text(encoding = "utf-8")
|
|
|
|
|
|
class TestAmdBnbFloorParity:
|
|
"""bitsandbytes must be at least 0.50.0 on AMD GPUs, since 0.49.2 and older NaN at 4-bit decode."""
|
|
|
|
FLOOR = "0.50.0"
|
|
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
|
|
|
def test_amd_extra_floor(self):
|
|
text = self.PYPROJECT.read_text(encoding = "utf-8")
|
|
amd = re.search(r"^amd = \[(.*?)^\]", text, re.S | re.M)
|
|
assert amd, "pyproject.toml must define an `amd` extra"
|
|
specs = re.findall(r'"(bitsandbytes[^"]*)"', amd.group(1))
|
|
assert specs, "the amd extra must pin bitsandbytes"
|
|
for spec in specs:
|
|
assert spec.startswith(
|
|
f"bitsandbytes>={self.FLOOR}"
|
|
), f"amd extra bitsandbytes floor must be >={self.FLOOR}, got {spec!r}"
|
|
|
|
def test_install_sh_pypi_fallback_floor(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert (
|
|
f'_BNB_ROCM_PYPI_FALLBACK="bitsandbytes>={self.FLOOR}"' in text
|
|
), f"install.sh _install_bnb_rocm PyPI fallback must floor at {self.FLOOR}"
|
|
|
|
def test_stack_py_pypi_fallback_floor(self):
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
assert (
|
|
f'_BNB_ROCM_PYPI_FALLBACK = "bitsandbytes>={self.FLOOR}"' in text
|
|
), f"install_python_stack.py PyPI fallback must floor at {self.FLOOR}"
|
|
|
|
def test_no_installer_still_allows_the_broken_range(self):
|
|
for path in (INSTALL_SH, INSTALL_PS1, SETUP_PS1, STACK_PY, self.PYPROJECT):
|
|
text = path.read_text(encoding = "utf-8")
|
|
for line in text.splitlines():
|
|
if "bitsandbytes>=0.49" in line and not line.lstrip().startswith(("#", "//")):
|
|
raise AssertionError(
|
|
f"{path.name} still floors bitsandbytes in the broken ROCm range: {line.strip()!r}"
|
|
)
|
|
|
|
def test_fallback_is_not_reported_as_broken(self):
|
|
"""The fallback now installs the first fixed release, so neither installer
|
|
may still call 4-bit decode broken on ROCm."""
|
|
for path in (INSTALL_SH, STACK_PY):
|
|
text = path.read_text(encoding = "utf-8")
|
|
assert (
|
|
"4-bit decode broken on ROCm" not in text
|
|
), f"{path.name} still reports the repaired PyPI fallback as broken"
|
|
assert (
|
|
"4-bit decode will be broken on ROCm" not in text
|
|
), f"{path.name} still reports the repaired PyPI fallback as broken"
|
|
|
|
def test_aarch64_is_not_told_it_has_a_rocm_backend(self):
|
|
"""bitsandbytes ships no ROCm kernels in its aarch64 wheel at any version, so
|
|
neither installer may hand aarch64 the x86_64 "carries the ROCm 4-bit fix"
|
|
message, and both must warn that 4-bit needs a source build there."""
|
|
sh = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert "_bnb_rocm_arch_has_binary()" in sh
|
|
assert "_warn_bnb_no_rocm_binary()" in sh
|
|
assert (
|
|
sh.count("_warn_bnb_no_rocm_binary\n") >= 2
|
|
), "install.sh must warn on aarch64 after both the pre-release and the fallback install"
|
|
py = STACK_PY.read_text(encoding = "utf-8")
|
|
assert "def _bnb_rocm_arch_has_binary(" in py
|
|
assert "_bnb_rocm_arch_has_binary()" in py
|
|
for text, name in ((sh, "install.sh"), (py, "install_python_stack.py")):
|
|
assert (
|
|
"4-bit QLoRA needs a source build" in text
|
|
), f"{name} must tell aarch64 users 4-bit needs a source build"
|
|
|
|
|
|
class TestInstallUvCacheRootParity:
|
|
"""Both top-level installers must expose the same automatic cache policy."""
|
|
|
|
def test_option_and_environment_names_match(self):
|
|
sh = INSTALL_SH.read_text(encoding = "utf-8")
|
|
ps1 = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
for source in (sh, ps1):
|
|
assert "--isolated-uv-cache" in source
|
|
assert "UNSLOTH_ISOLATE_UV_CACHE" in source
|
|
assert "--isolated-uv-cache" in source[:1200]
|
|
assert "UNSLOTH_ISOLATE_UV_CACHE" in source[:1200]
|
|
|
|
assert "_ISOLATE_UV_CACHE=false" in sh
|
|
assert "--isolated-uv-cache) _ISOLATE_UV_CACHE=true" in sh
|
|
assert "$IsolateUvCache = $false" in ps1
|
|
assert '"--isolated-uv-cache" { $IsolateUvCache = $true }' in ps1
|
|
assert "$env:UNSLOTH_ISOLATE_UV_CACHE -in @('1', 'true', 'yes', 'on')" in ps1
|
|
|
|
def test_selectors_share_precedence_markers_modes_and_messages(self):
|
|
sh = INSTALL_SH.read_text(encoding = "utf-8")
|
|
ps1 = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
for source in (sh, ps1):
|
|
for marker in ("CACHEDIR.TAG", ".gitignore"):
|
|
assert marker in source
|
|
for mode in ("custom", "shared", "studio", "isolated"):
|
|
assert f'"{mode}"' in source or f"'{mode}'" in source or f"={mode}" in source
|
|
for message in (
|
|
"preserving custom UV_CACHE_DIR",
|
|
"reusing existing shared cache",
|
|
"avoid duplicate Torch/CUDA downloads",
|
|
"using new Unsloth Studio-owned cache",
|
|
"already-cached packages may download again",
|
|
"so cached packages may download again",
|
|
):
|
|
assert message in source
|
|
for bucket in ("archive", "builds", "built-wheels", "wheels", "sdists"):
|
|
assert bucket in source, bucket
|
|
for metadata_suffix in (".msgpack", ".http", ".rev", ".lock"):
|
|
assert metadata_suffix in source, metadata_suffix
|
|
|
|
def test_both_installers_record_the_cache_they_chose(self):
|
|
"""`unsloth studio update` reads this to reuse the install's cache. Content
|
|
cannot decide it: install.sh:705 points the backend at the Studio cache even in
|
|
shared mode, so a runtime install leaves bytes in the losing one."""
|
|
sh = INSTALL_SH.read_text(encoding = "utf-8")
|
|
ps1 = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
cli = (REPO_ROOT / "unsloth_cli" / "commands" / "studio.py").read_text(encoding = "utf-8")
|
|
|
|
for source in (sh, ps1, cli):
|
|
assert "uv-cache-dir" in source
|
|
|
|
assert "_record_uv_cache_choice() {" in sh
|
|
# Every mode must record, or a previous install's marker survives.
|
|
call_sites = [
|
|
match.start()
|
|
for match in re.finditer(
|
|
r"^[ \t]+(?:_uv_no_cache_requested \|\| )?_record_uv_cache_choice[ \t]*$",
|
|
sh,
|
|
re.MULTILINE,
|
|
)
|
|
]
|
|
assert len(call_sites) == 3, f"one call site per mode branch, found {len(call_sites)}"
|
|
assert (
|
|
sh.index("_UV_CACHE_MODE=custom")
|
|
< min(call_sites)
|
|
< sh.index("_UV_CACHE_MODE=isolated")
|
|
), "the custom branch records before it returns"
|
|
# Custom, isolated and selection record; UV_NO_CACHE deliberately records nothing.
|
|
assert ps1.count("Write-StudioUvCacheMarker -StudioRoot") == 3, ps1.count(
|
|
"Write-StudioUvCacheMarker -StudioRoot"
|
|
)
|
|
# A marker is a preference, so writes must not fail; helpers after it use Stop on purpose.
|
|
marker_write = ps1[
|
|
ps1.index("function Write-StudioUvCacheMarker") : ps1.index(
|
|
"function Test-StudioUvNoCache"
|
|
)
|
|
]
|
|
assert "-ErrorAction Stop" not in marker_write
|
|
assert marker_write.count("-ErrorAction SilentlyContinue") >= 2
|
|
assert "|| true" in sh[sh.index("_record_uv_cache_choice() {") :]
|
|
# Windows PowerShell 5.1 writes utf8 with a BOM.
|
|
assert "utf-8-sig" in cli
|
|
|
|
assert "_absolutize_uv_cache_dir() {" in sh
|
|
assert "UV_CACHE_DIR=$(_absolutize_uv_cache_dir)" in sh
|
|
assert "IsPathRooted" in ps1
|
|
|
|
# Asserts both installers carry the same selection rules, even where no shell can run.
|
|
# The marker outranks uv's default while it is still warm.
|
|
assert "cache/uv-cache-dir" in sh
|
|
assert "function Read-StudioUvCacheMarker" in ps1
|
|
for source in (sh, ps1):
|
|
assert "UV_NO_CACHE" in source
|
|
# clap's literals for UV_NO_CACHE; all three copies must match.
|
|
assert "1|y|yes|t|true|on)" in sh
|
|
assert '@("1", "y", "yes", "t", "true", "on")' in ps1
|
|
assert '_UV_TRUE = ("1", "y", "yes", "t", "true", "on")' in cli
|
|
# uv leaves UV_CACHE_DIR empty under --no-cache, so check it before recording a cache.
|
|
assert "_uv_no_cache_requested() {" in sh
|
|
assert "function Test-StudioUvNoCache" in ps1
|
|
sh_custom = sh[sh.index("_UV_CACHE_MODE=custom") :].split("return 0", 1)[0]
|
|
assert "_uv_no_cache_requested" in sh_custom, sh_custom
|
|
sh_iso = sh[sh.index("_UV_CACHE_MODE=isolated") :].split("return 0", 1)[0]
|
|
assert "_uv_no_cache_requested" in sh_iso, sh_iso
|
|
ps1_custom = ps1[ps1.index('$script:StudioUvCacheMode = "custom"') :].split(
|
|
"if ($Isolated)", 1
|
|
)[0]
|
|
assert "Test-StudioUvNoCache" in ps1_custom, ps1_custom
|
|
ps1_iso = ps1[ps1.index('$script:StudioUvCacheMode = "isolated"') :].split(
|
|
"Test-StudioUvNoCache)", 1
|
|
)
|
|
assert len(ps1_iso) == 2, "the isolated branch no longer guards its marker write"
|
|
assert "_uv_is_bucket_name() {" in sh
|
|
assert "function Test-StudioUvBucketName" in ps1
|
|
# `##*-v` on archive-v1-v leaves an empty version, which must also be rejected.
|
|
bucket_sh = sh.split("_uv_is_bucket_name() {", 1)[1].split("\n}", 1)[0]
|
|
assert "''|*[!0-9]*) return 1 ;;" in bucket_sh, bucket_sh
|
|
# Split at the next function: bodies are indented, so "\n}" overshoots.
|
|
bucket_ps1 = ps1.split("function Test-StudioUvBucketName", 1)[1].split(
|
|
"function Test-StudioUvCacheWritable", 1
|
|
)[0]
|
|
assert "IsNullOrEmpty($suffix)" in bucket_ps1, bucket_ps1
|
|
# Case folding is measured, not assumed: APFS/NTFS fold, ext4 does not.
|
|
assert "ToLowerInvariant()" in bucket_ps1, bucket_ps1
|
|
assert "-cin" not in bucket_ps1, bucket_ps1
|
|
assert "[switch]$Fold" in bucket_ps1, bucket_ps1
|
|
writable_ps1 = ps1.split("function Test-StudioUvCacheWritable", 1)[1].split(
|
|
"function Test-StudioUvCachePopulated", 1
|
|
)[0]
|
|
assert ".unsloth-case-probe." in writable_ps1, writable_ps1
|
|
assert "-Fold:$fold" in writable_ps1, writable_ps1
|
|
writable_sh = sh.split("_uv_cache_is_writable() {", 1)[1].split("\n}", 1)[0]
|
|
assert "tr '[:upper:]' '[:lower:]'" in writable_sh, writable_sh
|
|
# Probe with a fresh directory: an existing pair cannot show case folding.
|
|
assert ".unsloth-case-probe." in writable_sh, writable_sh
|
|
assert "_uv_w_fold=1" in writable_sh, writable_sh
|
|
assert writable_sh.index("_uv_w_fold") < writable_sh.index('[ ! -d "$_uv_w_dir" ]')
|
|
probe_kinds = {
|
|
"archive",
|
|
"binaries",
|
|
"builds",
|
|
"built-wheels",
|
|
"environments",
|
|
"flat-index",
|
|
"git",
|
|
"interpreter",
|
|
"osv",
|
|
"python",
|
|
"sdists",
|
|
"simple",
|
|
"wheels",
|
|
}
|
|
assert 'UV_PINNED_VERSION="0.12.1"' in sh, "re-read uv-cache/src/lib.rs for the new pin"
|
|
case_body = re.search(
|
|
r'case "\$\{1%-v\*\}" in\n(.*?)\n\s*\*\) return 1', bucket_sh, re.S
|
|
).group(1)
|
|
kinds_sh = set(re.findall(r"[a-z\-]+", case_body))
|
|
assert kinds_sh == probe_kinds, sorted(kinds_sh ^ probe_kinds)
|
|
kinds_ps1 = set(
|
|
re.findall(r'"([a-z\-]+)"', bucket_ps1.split("-in @(", 1)[1].split("))", 1)[0])
|
|
)
|
|
assert kinds_ps1 == probe_kinds, sorted(kinds_ps1 ^ probe_kinds)
|
|
# The CLI must apply the same suffix rule or update prefers a rejected cache.
|
|
assert "def _uv_is_bucket_name(name: str) -> bool:" in cli
|
|
assert (
|
|
'_UV_CACHE_BUCKETS = ("archive", "builds", "built-wheels", "wheels", "sdists")' in cli
|
|
)
|
|
assert ".unsloth-write-probe." in sh
|
|
assert ".unsloth-write-probe." in ps1
|
|
assert "function Test-StudioUvCacheWritable" in ps1
|
|
assert "_uv_cache_root_is_writable() {" in sh
|
|
assert "function Test-StudioUvCacheRootWritable" in ps1
|
|
# Root writable does not imply buckets are; both sites must check both.
|
|
assert "_uv_cache_is_writable() {" in sh
|
|
assert "function Test-StudioUvCacheUsable" in ps1
|
|
sh_launch = sh.split("_prepare_studio_uv_cache_for_launch() {", 1)[1].split("\n}", 1)[0]
|
|
assert "_uv_cache_is_writable" in sh_launch, sh_launch
|
|
ps1_launch = ps1.split("function Set-StudioUvCacheForLaunch", 1)[1].split("\n }", 1)[0]
|
|
assert "Test-StudioUvCacheUsable" in ps1_launch, ps1_launch
|
|
# uv renames into these dirs, and create can be granted while unlink is denied.
|
|
root_sh = sh.split("_uv_cache_root_is_writable() {", 1)[1].split("\n}", 1)[0]
|
|
# Judge by the probe being gone, with retries: scanners hold handles transiently.
|
|
assert '[ -e "$_uv_root_probe" ] || break' in root_sh, root_sh
|
|
assert "_uv_root_tries" in root_sh, root_sh
|
|
root_ps1 = ps1.split("function Test-StudioUvCacheRootWritable", 1)[1].split("\n }", 1)[0]
|
|
assert "[System.IO.File]::Exists($probe)" in root_ps1, root_ps1
|
|
assert "$attempt -lt 3" in root_ps1, root_ps1
|
|
|
|
# Under `irm | iex` script scope is the caller's session, so reset at the entry point.
|
|
entry = ps1.index("function Install-UnslothStudio {")
|
|
first_consumer = min(
|
|
ps1.index("Set-StudioUvCacheEnvironment -StudioRoot $StudioHome"),
|
|
ps1.index('(Exit-InstallFailure "', entry),
|
|
)
|
|
for variable in (
|
|
"$script:StudioUvMarkerSaved = $false",
|
|
"$script:StudioUvMarkerExisted = $false",
|
|
"$script:StudioUvMarkerPrevious = $null",
|
|
"$script:StudioInstallCommitted = $false",
|
|
):
|
|
assert variable in ps1[entry:first_consumer], variable
|
|
|
|
assert (
|
|
"$script:StudioUvMarkerSaved = $false"
|
|
in ps1[
|
|
ps1.index("function Complete-StudioVenvRollback") : ps1.index(
|
|
"function Complete-StudioVenvRollback"
|
|
)
|
|
+ 900
|
|
]
|
|
)
|
|
commit_start = sh.index("_commit_studio_venv_replacement() {")
|
|
commit_body = sh[commit_start : sh.index("\n}", commit_start)]
|
|
# Before the venv flag, so a signal between them reverts the marker.
|
|
assert commit_body.index("_UV_MARKER_SAVED=false") < commit_body.index(
|
|
"_VENV_ROLLBACK_ACTIVE=false"
|
|
)
|
|
ps1_commit = ps1[ps1.index("function Complete-StudioVenvRollback") :][:900]
|
|
assert ps1_commit.index("$script:StudioUvMarkerSaved = $false") < ps1_commit.index(
|
|
"$script:StudioVenvRollbackActive = $false"
|
|
)
|
|
assert commit_body.index("_UV_MARKER_SAVED=false") < commit_body.index(
|
|
'if [ "$_VENV_ROLLBACK_ACTIVE" = true ]'
|
|
)
|
|
assert ps1_commit.index("$script:StudioUvMarkerSaved = $false") < ps1_commit.index(
|
|
"if (-not $script:StudioVenvRollbackActive) { return }"
|
|
)
|
|
|
|
# The unlink keeps a symlinked marker from truncating its target and can fail silently.
|
|
for body in (
|
|
sh[sh.index("_record_uv_cache_choice() {") : sh.index("_restore_uv_cache_marker() {")],
|
|
sh[sh.index("_restore_uv_cache_marker() {") :][:600],
|
|
):
|
|
unlink = body.index('rm -f "$_uv_marker_file"')
|
|
write = body.index("printf '%s\\n'", unlink)
|
|
assert '[ -L "$_uv_marker_file" ]' in body[unlink:write], body[unlink:write]
|
|
ps1_marker = ps1[
|
|
ps1.index("function Write-StudioUvCacheMarker") : ps1.index(
|
|
"function Test-StudioUvNoCache"
|
|
)
|
|
]
|
|
# One resolver for all four paths, or they disagree once UV_WORKING_DIR is set.
|
|
assert ps1.count("Resolve-StudioUvCachePath -Cache") == 4, ps1.count(
|
|
"Resolve-StudioUvCachePath -Cache"
|
|
)
|
|
assert "$env:UV_CACHE_DIR = Resolve-StudioUvCachePath -Cache $env:UV_CACHE_DIR" in ps1
|
|
assert "UV_WORKING_DIR" in sh[sh.index("_absolutize_uv_cache_dir() {") :][:600]
|
|
assert "UV_WORKING_DIR" in ps1[ps1.index("function Resolve-StudioUvCachePath") :][:800]
|
|
for start in _all_indexes(ps1_marker, "Remove-Item -LiteralPath $markerFile"):
|
|
window = ps1_marker[
|
|
start : ps1_marker.index("Set-Content -LiteralPath $markerFile", start)
|
|
]
|
|
assert "Get-Item -LiteralPath $markerFile -Force" in window, window
|
|
|
|
# 5.1 reads the BOM-less UTF-8 marker as ANSI; source check since pwsh 7 passes either way.
|
|
read_back = ps1_marker.index("Get-Content -LiteralPath $markerFile")
|
|
assert "-Encoding UTF8" in ps1_marker[read_back : read_back + 220], ps1_marker[read_back:]
|
|
|
|
# One flag decides both rollbacks, so a signal cannot restore half a committed install.
|
|
assert commit_body.index("_STUDIO_INSTALL_COMMITTED=true") < commit_body.index(
|
|
"_UV_MARKER_SAVED=false"
|
|
)
|
|
for body in (
|
|
sh[sh.index("_restore_studio_venv_replacement() {") :][:400],
|
|
sh[sh.index("_restore_uv_cache_marker() {") :][:400],
|
|
):
|
|
assert "_STUDIO_INSTALL_COMMITTED" in body, body
|
|
assert ps1_commit.index("$script:StudioInstallCommitted = $true") < ps1_commit.index(
|
|
"$script:StudioUvMarkerSaved = $false"
|
|
)
|
|
for name in ("Restore-StudioVenvRollback", "Restore-StudioUvCacheMarker"):
|
|
body = ps1[ps1.index(f"function {name} {{") :][:800]
|
|
assert "if ($script:StudioInstallCommitted) { return }" in body, name
|
|
|
|
assert "_restore_uv_cache_marker" in sh[sh.index("_on_install_exit() {") :]
|
|
assert "_restore_uv_cache_marker" in sh[sh.index("_on_install_signal() {") :]
|
|
assert ps1.count("Restore-StudioUvCacheMarker -StudioRoot") == 2
|
|
|
|
assert "_restore_uv_cache_marker" in sh
|
|
assert sh.count("_restore_uv_cache_marker") >= 2, "defined but never called"
|
|
assert "function Restore-StudioUvCacheMarker" in ps1
|
|
|
|
assert "${XDG_CACHE_HOME}/uv" in sh
|
|
assert "${HOME}/.cache/uv" in sh
|
|
assert 'Join-Path (Join-Path $env:LOCALAPPDATA "uv") "cache"' in ps1
|
|
# The warmth scan lives in Test-StudioUvCachePopulated, so slice from there.
|
|
selector = ps1.split("function Test-StudioUvBucketName", 1)[1].split(
|
|
"function Set-StudioUvCacheForLaunch", 1
|
|
)[0]
|
|
assert "Get-ChildItem -LiteralPath $Cache -Directory -Force" in selector
|
|
assert "Get-ChildItem -LiteralPath $bucket.FullName -File -Recurse -Force" in selector
|
|
# Must not fail closed: one denied subdirectory would read as an empty cache.
|
|
assert "-ErrorAction SilentlyContinue -ErrorVariable scanErrors" in selector
|
|
# The scan only; later helpers use Stop on purpose, so the slice ends at the next function.
|
|
bucket_loop = selector.split("foreach ($bucket in $buckets)", 1)[1].split(
|
|
"function Test-StudioUvCacheRootWritable", 1
|
|
)[0]
|
|
assert "-ErrorAction Stop" not in bucket_loop, bucket_loop
|
|
assert "find -L " in sh
|
|
|
|
def test_shell_order_wsl_handoff_and_autostart_boundary(self):
|
|
source = INSTALL_SH.read_text(encoding = "utf-8")
|
|
resolved = source.index("\n_resolve_studio_destinations\n")
|
|
uv_setup = source.index("\n# ── Install uv ──\n")
|
|
configured = source.index("\n_configure_uv_cache\n", uv_setup)
|
|
launch_boundary = source.index("_prepare_studio_uv_cache_for_launch\n", configured)
|
|
autostart = source.index(
|
|
'(trap - INT; exec "$VENV_DIR/bin/unsloth" studio', launch_boundary
|
|
)
|
|
|
|
assert "_configure_uv_cache() {" in source
|
|
assert "_prepare_studio_uv_cache_for_launch() {" in source
|
|
assert resolved < uv_setup < configured < launch_boundary < autostart
|
|
reroute = source.split("_maybe_reroute_strixhalo_to_2404() {", 1)[1].split(
|
|
"_maybe_reroute_strixhalo_to_2404 || true", 1
|
|
)[0]
|
|
assert "export UNSLOTH_ISOLATE_UV_CACHE=1" in reroute
|
|
assert "unset UV_CACHE_DIR" in reroute
|
|
assert 'case "${UV_CACHE_DIR-}" in' in reroute
|
|
assert "*[![:space:]]*)" in reroute
|
|
|
|
def test_powershell_order_handoff_and_restoration(self):
|
|
source = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
resolved = source.index('$VenvDir = Join-Path $StudioHome "unsloth_studio"')
|
|
captured = source.index("$hadPreviousUvCacheDir =")
|
|
uv_setup = source.index("if (-not (Test-UvVersionOk))", captured)
|
|
configured = source.index(
|
|
"Set-StudioUvCacheEnvironment -StudioRoot $StudioHome -Isolated $IsolateUvCache",
|
|
uv_setup,
|
|
)
|
|
tauri_return = source.index("if ($TauriMode)", configured)
|
|
handoff = source.index("Set-StudioUvCacheForLaunch -StudioRoot $StudioHome", tauri_return)
|
|
autostart = source.index("$studioAutoStartProcess = Start-Process", handoff)
|
|
restored = source.index("Restore-StudioUvCacheEnvironment -WasPresent", autostart)
|
|
|
|
assert (
|
|
resolved
|
|
< captured
|
|
< uv_setup
|
|
< configured
|
|
< tauri_return
|
|
< handoff
|
|
< autostart
|
|
< restored
|
|
)
|
|
selector = source.split("function Set-StudioUvCacheEnvironment", 1)[1].split(
|
|
"function Set-StudioUvCacheForLaunch", 1
|
|
)[0]
|
|
assert "Read-Host" not in selector
|
|
assert "Set-StudioUvCacheForLaunch" in source
|
|
assert "Set-Item -LiteralPath Env:UV_CACHE_DIR -Value $PreviousValue" in source
|
|
assert "Remove-Item -LiteralPath Env:UV_CACHE_DIR" in source
|
|
|
|
|
|
class TestWindowsMountPointVolumes:
|
|
"""Volumes can mount at a directory, so free space must come from Win32_Volume, not the drive root."""
|
|
|
|
def test_free_space_asks_the_mounted_volume_first(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert "Win32_Volume" in text, "install.ps1 does not consult the mount-point view"
|
|
helper = text.split("function Get-StudioFreeSpaceBytes", 1)[1].split(
|
|
"function Get-StudioTreeSizeBytes", 1
|
|
)[0]
|
|
assert (
|
|
"Get-StudioMountedVolume" in helper
|
|
), "Get-StudioFreeSpaceBytes falls straight through to the drive root"
|
|
assert "DriveInfo" in helper, "the drive-root fallback was dropped"
|
|
|
|
def test_the_probe_is_gated_on_windows(self):
|
|
# CimCmdlets ships only on Windows, and this helper runs on every platform.
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
helper = text.split("function Get-StudioMountedVolume", 1)[1].split("\n }", 1)[0]
|
|
assert (
|
|
"$IsWindows" in helper and "Windows_NT" in helper
|
|
), "Get-StudioMountedVolume would call Get-CimInstance off Windows"
|
|
|
|
|
|
class TestDiskFullDiagnosisReachesTauri:
|
|
"""The diagnosis must be folded into the ERROR_DEFAULT marker, the only text --tauri users see."""
|
|
|
|
def test_shell_folds_the_diagnosis_into_the_marker(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert (
|
|
'tauri_log "ERROR_DEFAULT" "studio setup failed (exit code $_SETUP_EXIT)$_DISK_FULL_SUFFIX"'
|
|
in text
|
|
), "install.sh keeps the disk-full diagnosis out of the Tauri message"
|
|
|
|
def test_windows_folds_the_diagnosis_into_the_failure_message(self):
|
|
"""Appended inside Exit-InstallFailure, so every caller carries it rather than the one
|
|
site that remembered to build a suffix."""
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
body = text.split("function Exit-InstallFailure", 1)[1].split("\n }", 1)[0]
|
|
append = body.index('$Message = "$Message$_diskSuffix"')
|
|
marker = body.index('Write-TauriLog "ERROR_DEFAULT" $Message')
|
|
assert append < marker, "the diagnosis is appended after the Tauri marker is written"
|
|
|
|
def test_windows_measures_in_both_modes(self):
|
|
# Outside the non-Tauri console branch, or --tauri never measures.
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
body = text.split("function Exit-InstallFailure", 1)[1].split("\n }", 1)[0]
|
|
probe = body.index("Get-StudioFreeSpaceBytes")
|
|
guard = body.index("if (-not $TauriMode) {")
|
|
assert (
|
|
probe < guard
|
|
), "the free-space probe runs only in the non-Tauri branch, so --tauri never measures"
|
|
|
|
|
|
class TestDiskFullDiagnosisCoversTheBiggestWrites:
|
|
"""Diagnosis goes on the shared exit funnel, as venv and torch writes fail long before studio setup."""
|
|
|
|
def test_windows_attaches_it_to_the_shared_exit(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
body = text.split("function Exit-InstallFailure", 1)[1].split("\n }", 1)[0]
|
|
assert "Get-StudioFreeSpaceBytes" in body, "only the studio-setup branch is diagnosed"
|
|
assert (
|
|
"Get-Command Get-StudioFreeSpaceBytes" in body
|
|
), "an exit before the helper is defined would report command-not-found instead"
|
|
|
|
def test_the_shell_attaches_it_to_the_exit_trap(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
body = text.split("_on_install_exit() {", 1)[1].split("\n}", 1)[0]
|
|
assert "_set_disk_full_suffix" in body, "earlier failures exit without a diagnosis"
|
|
assert (
|
|
"command -v _set_disk_full_suffix" in body
|
|
), "argument parsing exits before the helper exists"
|
|
|
|
def test_the_shell_does_not_report_it_twice(self):
|
|
"""The studio-setup branch reports with its own wording, and the trap runs afterwards."""
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert "_DISK_FULL_REPORTED=true" in text
|
|
assert '"${_DISK_FULL_REPORTED:-false}" != true' in text
|
|
|
|
|
|
class TestDiagnosticsNeverCostTheRollback:
|
|
"""Measure, then restore, then report; a failed report write under set -e would abort the exit trap."""
|
|
|
|
def test_the_shell_restores_before_it_reports(self):
|
|
"""Measure first, restore, then report. Reporting before the restore lets a failed write
|
|
abort the trap; measuring after it reads a disk the restore has just emptied."""
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
body = text.split("_on_install_exit() {", 1)[1].split("\n}", 1)[0]
|
|
measure = body.index("_set_disk_full_suffix || true")
|
|
restore = body.index("_restore_studio_venv_replacement")
|
|
report = body.index("tauri_log")
|
|
assert measure < restore, "the restore frees space before the disk is measured"
|
|
assert restore < report, "a failed diagnostic write can abort the trap before the restore"
|
|
|
|
def test_the_windows_probe_precedes_its_restore_too(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
body = text.split("function Exit-InstallFailure", 1)[1].split("\n }", 1)[0]
|
|
assert body.index("Get-StudioFreeSpaceBytes") < body.index(
|
|
"Restore-StudioVenvRollback"
|
|
), "the rollback restore frees space before the disk is measured"
|
|
|
|
def test_the_shell_diagnostics_are_best_effort(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
body = text.split("_on_install_exit() {", 1)[1].split("\n}", 1)[0]
|
|
writes = [
|
|
line.strip()
|
|
for line in body.splitlines()
|
|
if ("echo " in line or "tauri_log " in line) and not line.strip().startswith("#")
|
|
]
|
|
assert writes, "the trap no longer writes anything, so this test pins nothing"
|
|
for line in writes:
|
|
assert line.endswith("|| true"), f"under set -e this write can abort the trap: {line}"
|
|
|
|
def test_the_windows_diagnosis_cannot_escape(self):
|
|
"""PowerShell has no `set -e`, but the installer body runs under $ErrorActionPreference
|
|
Stop, so a write to a closed handle throws. Containing it is the same requirement."""
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
body = text.split("function Exit-InstallFailure", 1)[1].split("\n }", 1)[0]
|
|
probe = body.index("Get-StudioFreeSpaceBytes")
|
|
opened = body.rindex("try {", 0, probe)
|
|
closed = body.index("} catch", probe)
|
|
assert opened < probe < closed, "the disk probe is not inside a try/catch"
|
|
|
|
|
|
class TestDiskFullRemedyDescribesWhatHappened:
|
|
"""The out-of-space remedy must depend on what happened to the old environment, not on the flag used."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"path, leftover",
|
|
[
|
|
(INSTALL_SH, "_VENV_DISCARD_LEFTOVER"),
|
|
(INSTALL_PS1, "$script:StudioVenvDiscardLeftover"),
|
|
],
|
|
ids = ["install.sh", "install.ps1"],
|
|
)
|
|
def test_a_failed_discard_names_the_tree_it_left(self, path, leftover):
|
|
text = path.read_text(encoding = "utf-8")
|
|
block = text.split("is very likely the cause", 1)[0][-2200:]
|
|
assert leftover in block, f"{path.name} does not consult what the discard actually left"
|
|
assert (
|
|
"deleting it will reclaim that space" in text
|
|
), f"{path.name} never points at the tree the user can delete"
|
|
|
|
@pytest.mark.parametrize(
|
|
"path, succeeded",
|
|
[
|
|
(INSTALL_SH, "_VENV_DISCARDED"),
|
|
(INSTALL_PS1, "$script:StudioVenvDiscardSucceeded"),
|
|
],
|
|
ids = ["install.sh", "install.ps1"],
|
|
)
|
|
def test_the_already_discarded_case_reads_the_outcome_not_the_flag(self, path, succeeded):
|
|
"""Inferring it from the flag also claims a fresh install discarded something."""
|
|
text = path.read_text(encoding = "utf-8")
|
|
block = text.split("is very likely the cause", 1)[0][-2200:]
|
|
assert succeeded in block, f"{path.name} infers the discard from the request"
|
|
assert "already discarded by --no-rollback" in text
|
|
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
[INSTALL_SH, INSTALL_PS1],
|
|
ids = ["install.sh", "install.ps1"],
|
|
)
|
|
def test_the_flag_is_not_advised_to_a_run_that_used_it(self, path):
|
|
"""The remedy's payload is the opt-out's name, so offering it to someone who already
|
|
passed it describes a re-run that fails the same way."""
|
|
text = path.read_text(encoding = "utf-8")
|
|
block = text.split("is very likely the cause", 1)[0][-2200:]
|
|
assert (
|
|
"UNSLOTH_INSTALL_NO_ROLLBACK=1) drops the previous environment" in block
|
|
), f"{path.name} lost the remedy for a run that never opted out"
|
|
flag = "_NO_ROLLBACK" if path is INSTALL_SH else "StudioNoRollback"
|
|
assert flag in block, f"{path.name} offers the flag unconditionally"
|
|
|
|
|
|
class TestVolumeLookupsResolveLinks:
|
|
"""Volume lookups must resolve junctions and symlinks, which misreport the volume a path is on."""
|
|
|
|
def test_both_volume_lookups_go_through_the_resolver(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert "function Resolve-StudioVolumeQueryPath" in text
|
|
for name, end in (
|
|
("function Get-StudioMountedVolume", "function Get-StudioFreeSpaceBytes"),
|
|
("function Get-StudioFreeSpaceBytes", "function Remove-StudioVenvTreeWithRetry"),
|
|
):
|
|
helper = text.split(name, 1)[1].split(end, 1)[0]
|
|
assert (
|
|
"Resolve-StudioVolumeQueryPath" in helper
|
|
), f"{name} asks the lexical path, which a junction answers for the wrong volume"
|
|
|
|
def test_the_driveinfo_fallback_uses_the_resolved_path(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
helper = text.split("function Get-StudioFreeSpaceBytes", 1)[1].split(
|
|
"function Get-StudioTreeSizeBytes", 1
|
|
)[0]
|
|
assert (
|
|
"GetFullPath($queryPath)" in helper
|
|
), "the DriveInfo fallback still measures the unresolved path"
|
|
|
|
|
|
class TestNoRollbackDoesNotNarrowDeviceDetection:
|
|
"""Take the XPU verdict before discarding the old tree, or Intel Arc gets CPU wheels."""
|
|
|
|
def test_the_verdict_is_taken_before_the_tree_is_deleted(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
discard = text.split("if ($script:StudioNoRollback) {", 1)[1].split(
|
|
'substep "previous environment discarded', 1
|
|
)[0]
|
|
probe = discard.index("Invoke-BoundedPythonProbe")
|
|
removal = discard.index("Remove-StudioVenvTreeWithRetry")
|
|
assert probe < removal, "the tree is deleted before its XPU verdict is taken"
|
|
|
|
def test_the_scan_reads_the_preserved_verdict(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert "$script:StudioPreservedXpuVerdict" in text
|
|
scan = text.split("$_xpuProbePy = $VenvPython", 1)[0][-900:]
|
|
assert (
|
|
"$script:StudioPreservedXpuVerdict" in scan
|
|
), "the Intel scan never consults the verdict taken before the discard"
|
|
|
|
def test_the_probe_is_defined_before_the_rollback_that_calls_it(self):
|
|
# PowerShell binds a function when its definition runs; define helpers before callers.
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert text.index("function Invoke-BoundedPythonProbe") < text.index(
|
|
"function Start-StudioVenvRollback"
|
|
), "Invoke-BoundedPythonProbe is defined after the function that calls it"
|
|
|
|
|
|
class TestTheVolumeQueryIsBounded:
|
|
"""Win32_Volume query must run out of process with a deadline, as WMI can block forever."""
|
|
|
|
def test_it_runs_out_of_process_with_a_deadline(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
helper = text.split("function Get-StudioVolumeList", 1)[1].split(
|
|
"function Get-StudioMountedVolume", 1
|
|
)[0]
|
|
assert (
|
|
"Start-Job" in helper and "Wait-Job" in helper and "-Timeout" in helper
|
|
), "the Win32_Volume query is not bounded by a wall-clock deadline"
|
|
assert "Stop-Job" in helper, "a query past its deadline is never killed"
|
|
|
|
def test_nothing_queries_win32_volume_unbounded(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
helper = text.split("function Get-StudioVolumeList", 1)[1].split(
|
|
"function Get-StudioMountedVolume", 1
|
|
)[0]
|
|
assert (
|
|
text.count("Win32_Volume") == helper.count("Win32_Volume") + 1
|
|
), "Win32_Volume is queried somewhere other than the bounded helper"
|
|
|
|
def test_the_answer_is_taken_once(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert (
|
|
"$script:StudioVolumeList = $null" in text
|
|
), "the cached volume list is never reset, so `irm | iex` reuses a previous run's answer"
|
|
|
|
|
|
class TestFreeSpaceIsNeverServedFromTheCache:
|
|
"""Cached volume rows are stale for FreeSpace, so the disk-full check must ask for a fresh figure."""
|
|
|
|
def test_the_free_space_caller_asks_for_a_fresh_answer(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
helper = text.split("function Get-StudioFreeSpaceBytes", 1)[1].split(
|
|
"function Get-StudioTreeSizeBytes", 1
|
|
)[0]
|
|
assert "-Fresh" in helper, "free space is served from a snapshot taken earlier in the run"
|
|
|
|
|
|
class TestVolumeLookupRefusesToGuess:
|
|
"""Unrooted Volume{GUID} paths are rejected, since GetFullPath would anchor them to the cwd."""
|
|
|
|
def test_the_matcher_rejects_an_unrooted_path(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
helper = text.split("function Select-StudioVolumeForPath", 1)[1].split(
|
|
"function Get-StudioVolumeList", 1
|
|
)[0]
|
|
assert "IsPathRooted" in helper, "the matcher anchors an unrooted path to the CWD"
|
|
|
|
def test_the_resolver_keeps_the_caller_path_when_resolution_is_unrooted(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
helper = text.split("function Resolve-StudioVolumeQueryPath", 1)[1].split("\n }", 1)[0]
|
|
assert (
|
|
"IsPathRooted" in helper
|
|
), "the resolver hands on an unrooted result instead of the caller's own path"
|
|
|
|
|
|
class TestRemovalIsConfirmedWithLinkAwareSemantics:
|
|
"""Removal is confirmed link-aware: Test-Path follows a dangling reparse point and reports it absent."""
|
|
|
|
def test_the_retry_helper_uses_the_link_aware_check(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
helper = text.split("function Remove-StudioVenvTreeWithRetry", 1)[1].split(
|
|
"\n function ", 1
|
|
)[0]
|
|
assert (
|
|
"Test-StudioPathPresent" in helper
|
|
), "the retry helper confirms removal with Test-Path, which a dangling link fools"
|
|
assert "if (-not (Test-Path -LiteralPath $Path))" not in helper
|
|
|
|
|
|
class TestArm64MigrationDoesNotPromiseWhatTheFlagDeletes:
|
|
"""Only promise the kept unsloth_studio.arm64.* environment when --no-rollback did not discard it."""
|
|
|
|
def test_the_promise_is_gated_on_the_flag_too(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
gate = text.split("the ARM64 environment is kept under", 1)[0][-400:]
|
|
assert (
|
|
"$script:StudioNoRollback" in gate
|
|
), "the retention promise is printed on a run whose next call deletes the tree"
|
|
|
|
def test_a_discard_inside_the_migration_is_reported(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
arm = text.split("Start-StudioVenvRollback -ExistingDir $VenvDir\n if (", 1)
|
|
assert len(arm) == 2, "the migration does not check what its own rollback call did"
|
|
assert "discarded by --no-rollback rather than kept" in arm[1][:900]
|
|
|
|
|
|
class TestNoRollbackNeverPromisesAKeptCopy:
|
|
"""Every message printed before a call that may discard has to vary on the flag. Three sites
|
|
promised a recoverable copy and then deleted it: the ordinary reinstall, the ARM64 mismatch
|
|
rebuild, and the Windows-on-ARM native-CUDA migration rebuild (#11313)."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"path, flag",
|
|
[
|
|
(INSTALL_SH, '[ "${_NO_ROLLBACK:-false}" = true ]'),
|
|
(INSTALL_PS1, "if ($script:StudioNoRollback)"),
|
|
],
|
|
ids = ["install.sh", "install.ps1"],
|
|
)
|
|
def test_the_reinstall_message_varies(self, path, flag):
|
|
text = path.read_text(encoding = "utf-8")
|
|
before = text.split("preserving existing environment for rollback", 1)[0][-500:]
|
|
assert flag in before, f"{path.name} promises a rollback copy it may be about to discard"
|
|
|
|
def test_the_woa_migration_message_varies(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
before = text.split("the previous one is kept for rollback", 1)[0][-600:]
|
|
assert (
|
|
"$script:StudioNoRollback" in before
|
|
), "the Windows-on-ARM migration rebuild promises a copy --no-rollback deletes"
|
|
|
|
def test_every_rollback_call_site_was_audited(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert text.count("Start-StudioVenvRollback -ExistingDir") == 3
|