diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml
index 081eda4e32..0a8d71610d 100644
--- a/.github/workflows/release-desktop.yml
+++ b/.github/workflows/release-desktop.yml
@@ -766,6 +766,7 @@ jobs:
env:
GH_REPO: ${{ github.repository }}
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
+ PYPI_VERSION: ${{ needs.prepare-version.outputs.pypi_version }}
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
@@ -911,6 +912,8 @@ jobs:
notes = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-notes.md').read_text()
metadata = {
'version': os.environ['APP_VERSION'],
+ # App version is SemVer; CHANGELOG.md is keyed by the backend release.
+ 'pypi_version': os.environ['PYPI_VERSION'],
'notes': notes,
'pub_date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
'platforms': {
diff --git a/.github/workflows/startup-profile-ci.yml b/.github/workflows/startup-profile-ci.yml
new file mode 100644
index 0000000000..fbde99836d
--- /dev/null
+++ b/.github/workflows/startup-profile-ci.yml
@@ -0,0 +1,156 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
+
+# Measures where Studio's startup time goes, on each platform.
+#
+# Nothing recorded a number before: main.py logs "lifespan startup completed in X ms"
+# and studio_test_kit polls /healthz, but both throw the elapsed time away. A first
+# local run (Linux, warm cache, 18-core server) put `import main` at 5.7-6.6s BEFORE
+# the server can bind, dominated by eager module-level imports pulled in by routes:
+# torch ~1.9s self, unsloth_zoo ~0.8s, routes ~0.6s, transformers ~0.5s.
+#
+# Not a gate yet: --max-healthz-seconds exists, but a budget should come from
+# observed numbers rather than a guess.
+
+name: Startup profile
+
+on:
+ pull_request:
+ paths:
+ # The measured import graph is the whole backend tree: main.py imports auth,
+ # core, hub, loggers, models, picker, routes and utils at module scope.
+ - 'studio/backend/**'
+ - '!studio/backend/tests/**'
+ # The launch phase spawns `unsloth studio --api-only`, so the CLI counts too.
+ - 'unsloth_cli/**'
+ - 'studio/src-tauri/src/preflight**'
+ # The profiler hardcodes the desktop argv that process.rs::backend_args builds,
+ # so a change there must schedule a run or the two silently diverge.
+ - 'studio/src-tauri/src/process.rs'
+ - 'scripts/profile_startup.py'
+ - '.github/workflows/startup-profile-ci.yml'
+ # The job profiles whatever `install.sh --local` built: the installers pick the
+ # venv's Python and the dependency specs, and pyproject's include list is what
+ # makes --local overlay studio.backend*.
+ - 'install.sh'
+ - 'install.ps1'
+ - 'pyproject.toml'
+ # --local also runs the checkout's setup scripts (install.sh picks
+ # $_REPO_ROOT/studio/setup.sh, the editable install resolves setup.ps1 to the
+ # repo), and both call install_python_stack.py, which picks the dependencies.
+ - 'studio/setup.sh'
+ - 'studio/setup.ps1'
+ - 'studio/install_python_stack.py'
+ workflow_dispatch:
+ inputs:
+ repeats:
+ description: 'launch repeats per OS (median reported)'
+ type: string
+ default: '3'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ profile:
+ name: startup ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 60
+ continue-on-error: true
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-14, windows-latest]
+
+ env:
+ UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home
+ # A wildcard bind calls ifconfig.me on the startup path; loopback times our code.
+ UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1'
+
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Install Studio
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -o pipefail
+ mkdir -p logs
+ # --local is load-bearing: it overlays the checkout, so the profiled server
+ # is this diff. Without it install.sh resolves unsloth from PyPI.
+ if [ "${{ runner.os }}" = "Windows" ]; then
+ pwsh -NoProfile -File ./install.ps1 --local 2>&1 | tee logs/install.log
+ else
+ bash install.sh --local 2>&1 | tee logs/install.log
+ fi
+
+ - name: Profile startup
+ shell: bash
+ run: |
+ BIN="$UNSLOTH_STUDIO_HOME/unsloth_studio/bin/unsloth"
+ [ -x "$BIN" ] || BIN="$UNSLOTH_STUDIO_HOME/unsloth_studio/Scripts/unsloth.exe"
+ [ -x "$BIN" ] || BIN=""
+ # Profile imports with the INSTALLED interpreter: that venv is what launches.
+ PY="$UNSLOTH_STUDIO_HOME/unsloth_studio/bin/python"
+ [ -x "$PY" ] || PY="$UNSLOTH_STUDIO_HOME/unsloth_studio/Scripts/python.exe"
+ [ -x "$PY" ] || PY="$(command -v python3 || command -v python)"
+ python3 scripts/profile_startup.py \
+ --python "$PY" \
+ ${BIN:+--bin "$BIN"} \
+ --repeats "${{ inputs.repeats || '3' }}" \
+ --json "startup-${{ matrix.os }}.json" 2>&1 | tee logs/profile.log
+
+ - name: Summary
+ if: always()
+ shell: bash
+ run: |
+ f="startup-${{ matrix.os }}.json"
+ [ -f "$f" ] || { echo "no profile produced"; exit 0; }
+ python3 - "$f" >> "$GITHUB_STEP_SUMMARY" <<'PY'
+ import json, sys
+ d = json.load(open(sys.argv[1]))
+ print(f"### {d['platform']} / {d['machine']} (py {d['python']}, {d['cpu_count']} cpu)\n")
+ imp = d.get("imports", {})
+ # Gate on ok: a failed `import main` still leaves rows, so a total can lie.
+ if imp.get("ok"):
+ print(f"**`import main`: {imp['total_seconds']}s**\n")
+ print("| package | self ms |")
+ print("|---|---:|")
+ for k, v in list(imp.get("self_by_package_ms", {}).items())[:8]:
+ print(f"| {k} | {v} |")
+ print()
+ else:
+ print("**`import main` failed - no valid import profile**\n")
+ print("```\n" + (imp.get("error") or "")[-1500:] + "\n```\n")
+ lau = d.get("launch") or {}
+ runs = len(lau.get("runs") or [])
+ failed = lau.get("failed_runs") or 0
+ if lau.get("healthz_median_seconds") is not None:
+ # The aggregates cover only the runs that reached healthz, so flag the
+ # failures: bare numbers would read as a normal fast startup.
+ note = f" _({runs - failed} of {runs} launches; {failed} never became healthy)_" if failed else ""
+ print(f"**time to a healthy port: {lau['healthz_median_seconds']}s median, "
+ f"{lau['healthz_max_seconds']}s max**{note}\n")
+ elif lau.get("skipped"):
+ print(f"_launch phase skipped: {lau['skipped']}_\n")
+ elif runs:
+ print(f"**no launch measurement: all {runs} launches failed to become healthy**\n")
+ PY
+
+ - name: Upload profile
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: startup-profile-${{ matrix.os }}
+ path: |
+ startup-*.json
+ logs/
+ retention-days: 14
+ if-no-files-found: warn
diff --git a/.gitignore b/.gitignore
index fafd17aa95..fa6997cb06 100644
--- a/.gitignore
+++ b/.gitignore
@@ -208,6 +208,9 @@ tmp/
**/node_modules/
auth.db
+# Packaging snapshot of the root CHANGELOG.md (written by build.sh)
+studio/CHANGELOG.md
+
# Tauri local build/generated output
studio/src-tauri/target/
studio/src-tauri/gen/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000000..241e013cea
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,88 @@
+# Changelog
+
+Release notes for Unsloth and Unsloth Studio.
+
+Unsloth Studio reads this file to show release notes inside the "New Unsloth
+version" update popup. Edit it here and the popup picks the change up on the
+next update check, with no release or rebuild required.
+
+## Format
+
+Every release is a level-2 heading whose first token is the version, optionally
+followed by a date:
+
+```md
+## 2026.7.6 - 2026-07-22
+```
+
+`## [2026.7.6] - 2026-07-22` and `## v2026.7.6` also work. Everything under a
+heading, up to the next level-2 heading, is that release's notes and renders as
+Markdown in the popup.
+
+Notes are matched to one exact version. When Studio offers an update to
+`2026.7.6` it renders the `2026.7.6` section and nothing else. If that section
+is missing, the popup links out to the online changelog rather than showing
+notes from an unrelated release, so a new version needs its own section here
+before its notes can appear.
+
+Keep the newest release at the top. Lead each bullet with the change itself:
+the collapsed popup highlights the first sentence and dims the rest.
+`## Unreleased` is ignored by the popup, so it is safe to stage notes there and
+rename the heading at release time.
+
+
+
+## Unreleased
+
+## 2026.7.5
+
+### What's Changed
+
+- AMD support is here. Train, run RL, chat with and deploy 500+ models on
+ Radeon, Instinct, Ryzen and data center GPUs across Windows, WSL and Linux,
+ up to 2x faster with 70% less VRAM and no accuracy loss.
+- Intel XPU support lands in Studio, so Arc and Data Center GPUs run chat and
+ training alongside the NVIDIA, AMD and Apple paths.
+- Local speech to text dictation runs fully offline, with slim Whisper bundles
+ and a picker for custom models.
+- DoRA training is available in Studio, selectable next to LoRA and full
+ fine-tuning in the training tab.
+- The update popup previews release notes inline, pulled from this file and
+ matched to the exact version being offered.
+
+### AMD, 23 July update
+
+Our AMD collaboration, custom Triton kernels and math algorithms bring local
+training and inference to AMD hardware. The 23 July update builds on the
+[AMD release](https://github.com/unslothai/unsloth/releases/tag/v0.1.501-beta):
+
+- RDNA2 and Gorgon Halo are supported, and the installer no longer fails to
+ detect GPUs on Strix Halo and other AMD cards.
+- RDNA4 handling is better, and HIP and ROCm failures are caught and fixed
+ automatically instead of stopping the install.
+- Unified memory safetensors loading is 2x faster, with much faster gradient
+ checkpointing on unified memory devices.
+- Voice dictation through whisper.cpp has preliminary support.
+- Rollback environments left by installs no longer eat 5GB of disk. They are
+ cleaned up automatically.
+
+Optimized ROCm builds cover GGUF and safetensors inference, and ROCm
+compatibility is improved for MI300X and MI325X. Full guide:
+[unsloth.ai/docs/basics/amd](https://unsloth.ai/docs/basics/amd).
+
+### Running larger models
+
+- Automatic GPU placement, or pick exactly which GPUs and layers to use.
+- Move MoE expert layers into system memory so larger models fit.
+- Split a model across several GPUs, or use tensor parallelism.
+- Hardware settings are saved per model and quant.
+
+### Also in this release
+
+- Remote access with `unsloth studio --secure` over free HTTPS via Cloudflare.
+- Web search reads PDF papers and manuals, and parallel tool calls, reasoning
+ output and tool retries are more reliable.
+- The model download location is configurable, so weights can live on a second
+ drive instead of the default cache.
+- Stalled Hugging Face XET downloads retry over standard HTTP, and existing
+ GGUF files are reused instead of downloaded again.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000000..7bce036343
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,2 @@
+include _changelog_build.py
+include CHANGELOG.md
diff --git a/_changelog_build.py b/_changelog_build.py
new file mode 100644
index 0000000000..f5bcf2052c
--- /dev/null
+++ b/_changelog_build.py
@@ -0,0 +1,36 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
+
+"""Snapshot CHANGELOG.md into the studio package at build time.
+
+CHANGELOG.md at the repo root stays the one file to edit. Copying it here,
+rather than in build.sh, means every packaging path ships it, so release notes
+still render when the popup cannot reach GitHub."""
+
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+
+from setuptools.command.build_py import build_py as _build_py
+
+ROOT = Path(__file__).resolve().parent
+SOURCE = ROOT / "CHANGELOG.md"
+SNAPSHOT = ROOT / "studio" / "CHANGELOG.md"
+
+
+class build_py(_build_py):
+ def run(self) -> None:
+ # Beside the sources only if writable (PEP 517 may build an immutable
+ # checkout); into the staging directory always.
+ if SOURCE.is_file():
+ try:
+ shutil.copyfile(SOURCE, SNAPSHOT)
+ except OSError:
+ pass
+ super().run()
+ if not SOURCE.is_file():
+ return
+ staged = Path(self.build_lib) / "studio" / "CHANGELOG.md"
+ staged.parent.mkdir(parents = True, exist_ok = True)
+ shutil.copyfile(SOURCE, staged)
diff --git a/build.sh b/build.sh
index 2a836e19d9..5b09a7791b 100644
--- a/build.sh
+++ b/build.sh
@@ -103,9 +103,13 @@ else
STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py)"
fi
-# 4. Build wheel/sdist
+# 4. Build wheel/sdist. _changelog_build.py snapshots CHANGELOG.md into the studio
+# package so release notes render offline.
python -m build
+# Drop the snapshot so a source checkout never serves a stale copy.
+rm -f studio/CHANGELOG.md
+
if [ "${1:-}" = "publish" ]; then
python scripts/stamp_studio_release.py --verify-dist dist --expected "$STUDIO_STAMPED_VERSION"
fi
diff --git a/install.ps1 b/install.ps1
index 8474520970..a26de28a19 100644
--- a/install.ps1
+++ b/install.ps1
@@ -57,6 +57,26 @@ function Install-UnslothStudio {
}
}
+ # Machine arch; Get-TauriDiagArch above reports the process. An emulated x64 shell on
+ # ARM64 reports AMD64, but PROCESSOR_ARCHITEW6432 is ARM64 in exactly that case.
+ function Get-HostMachineArch {
+ $osArch = ""
+ try { $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } catch { $osArch = "" }
+ $signals = @([string]$env:PROCESSOR_ARCHITEW6432, [string]$env:PROCESSOR_ARCHITECTURE, $osArch)
+ foreach ($s in $signals) {
+ if ($s.ToLowerInvariant() -eq "arm64") { return "arm64" }
+ }
+ foreach ($s in $signals) {
+ if ([string]::IsNullOrWhiteSpace($s)) { continue }
+ switch ($s.ToLowerInvariant()) {
+ "amd64" { return "x86_64" }
+ "x64" { return "x86_64" }
+ "x86" { return "x86" }
+ }
+ }
+ return "unknown"
+ }
+
function Get-TauriTorchIndexFamily {
param([string]$TorchIndexUrl)
if ($SkipTorch) { return "none" }
@@ -1124,10 +1144,27 @@ exit 0
return $false
}
+ # The interpreter's own arch, asked of it: win-amd64|win-arm64|win32|"".
+ function Get-PythonPlatformTag {
+ param([string]$Exe)
+ try {
+ return (& $Exe -c "import sysconfig; print(sysconfig.get_platform())" 2>$null | Out-String).Trim().ToLowerInvariant()
+ } catch { return "" }
+ }
+
# Returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null.
# The resolved Path is passed to `uv venv --python` to prevent uv from
# re-resolving the version string back to a conda interpreter.
function Find-CompatiblePython {
+ # -X64Only: best installed x64 interpreter or $null, never ARM64. Last resort for
+ # Install-X64Python, where x64 of a lower-priority minor beats ARM64.
+ param([switch]$X64Only)
+ # Windows on ARM: prefer x64. pyarrow (via datasets) and hf-transfer ship no
+ # win_arm64 wheel, so a native ARM64 Python source-builds both and dies on CMake /
+ # Rust minutes in; x64 runs fine emulated. ARM64 is still returned when it is all
+ # there is, and the caller then bootstraps x64 or warns.
+ $preferX64 = $X64Only -or ((Get-HostMachineArch) -eq "arm64")
+ $candidates = @()
# Try the Python Launcher first (most reliable on Windows)
# py.exe resolves to the standard CPython install, not conda.
# Prefer the requested $PythonVersion, then newest-first fallback.
@@ -1145,7 +1182,8 @@ exit 0
# Resolve the actual executable path and verify it is not conda-based
$resolvedExe = (& $pyLauncher.Source "-$minor" -c "import sys; print(sys.executable)" 2>$null | Out-String).Trim()
if ($resolvedExe -and (Test-Path $resolvedExe) -and -not (Test-IsCondaPython $resolvedExe)) {
- return @{ Version = $ver; Path = $resolvedExe }
+ if (-not $preferX64) { return @{ Version = $ver; Path = $resolvedExe; Arch = "" } }
+ $candidates += @{ Version = $ver; Path = $resolvedExe }
}
}
} catch {}
@@ -1166,11 +1204,53 @@ exit 0
try {
$out = & $cmd.Source --version 2>&1 | Out-String
if ($out -match "Python (3\.1[1-3])\.\d+") {
- return @{ Version = $Matches[1]; Path = $cmd.Source }
+ if (-not $preferX64) { return @{ Version = $Matches[1]; Path = $cmd.Source; Arch = "" } }
+ $candidates += @{ Version = $Matches[1]; Path = $cmd.Source }
}
} catch {}
}
}
+ # `py -3.12` runs the launcher's preferred build, normally the native ARM64 one, so
+ # a same-minor x64 install that is neither preferred nor on PATH never becomes a
+ # candidate. `-3.12-64` cannot disambiguate (deprecated, it only means "not
+ # 32-bit"), so enumerate every registration with -0p and probe each path.
+ if ($preferX64) {
+ foreach ($pyLauncher in @(Get-Command py -All -CommandType Application -ErrorAction SilentlyContinue)) {
+ if ($pyLauncher.Source -match $script:CondaSkipPattern) { continue }
+ $listed = @()
+ try { $listed = @(& $pyLauncher.Source "-0p" 2>$null) } catch {}
+ foreach ($line in $listed) {
+ # " -V:3.12 * C:\...\python.exe": tag, optional default marker, path.
+ $m = [regex]::Match([string]$line, '(?i)^\s*-\S+\s+\*?\s*"?(?
\S.*?\.exe)"?\s*$')
+ if (-not $m.Success) { continue }
+ $exe = $m.Groups['p'].Value.Trim()
+ if ($candidates | Where-Object { $_.Path -eq $exe }) { continue }
+ if (-not (Test-Path -LiteralPath $exe)) { continue }
+ if (Test-IsCondaPython $exe) { continue }
+ try {
+ $out = & $exe --version 2>&1 | Out-String
+ if ($out -match "Python (3\.1[1-3])\.\d+") {
+ $candidates += @{ Version = $Matches[1]; Path = $exe }
+ }
+ } catch {}
+ }
+ }
+ }
+ # Prefer x64, but only within one minor: $minors is the caller's version preference,
+ # so ranking on arch alone would answer UNSLOTH_PYTHON=3.12 with an x64 3.13 and
+ # never bootstrap x64 3.12. Probing costs a subprocess, so non-ARM returned above.
+ foreach ($c in $candidates) {
+ $tag = Get-PythonPlatformTag $c.Path
+ $c.Arch = if ($tag -eq "win-amd64") { "x86_64" } elseif ($tag -eq "win-arm64") { "arm64" } else { "unknown" }
+ }
+ foreach ($minor in $minors) {
+ $sameMinor = @($candidates | Where-Object { $_.Version -eq $minor })
+ if ($sameMinor.Count -eq 0) { continue }
+ $x64 = $sameMinor | Where-Object { $_.Arch -eq "x86_64" } | Select-Object -First 1
+ if ($x64) { return $x64 }
+ if (-not $X64Only) { return $sameMinor[0] }
+ }
+ if (-not $X64Only -and $candidates.Count -gt 0) { return $candidates[0] }
return $null
}
@@ -1181,8 +1261,11 @@ exit 0
# (no UAC), putting python.exe + the py launcher on PATH. Mirrors the uv ->
# astral.sh fallback below. Returns @{ Version; Path } or $null.
function Install-PythonFromPythonOrg {
+ # $Arch overrides the host arch, to pull x64 onto an ARM64 box.
+ param([string]$Arch = "")
# python.org ships one installer per architecture.
- $archSuffix = switch (Get-TauriDiagArch) {
+ $targetArch = if ($Arch) { $Arch } else { Get-TauriDiagArch }
+ $archSuffix = switch ($targetArch) {
"x86_64" { "-amd64" }
"arm64" { "-arm64" }
"x86" { "" }
@@ -1247,6 +1330,28 @@ exit 0
return (Find-CompatiblePython)
}
+ # ── Windows on ARM: get an x64 CPython ──
+ # --architecture x64 forces winget off the ARM64 build; python.org takes the same override.
+ function Install-X64Python {
+ if ($script:WingetAvailable) {
+ $prevEAP = $ErrorActionPreference
+ $ErrorActionPreference = "Continue"
+ try {
+ winget install -e --id "Python.Python.$PythonVersion" --source winget --architecture x64 --accept-package-agreements --accept-source-agreements
+ } catch { }
+ $ErrorActionPreference = $prevEAP
+ Refresh-SessionPath
+ $found = Find-CompatiblePython
+ if ($found -and $found.Arch -eq "x86_64") { return $found }
+ substep "winget could not provide an x64 Python -- trying python.org..." "Yellow"
+ }
+ $found = Install-PythonFromPythonOrg -Arch "x86_64"
+ if ($found -and $found.Arch -eq "x86_64") { return $found }
+ # Nothing installable (offline / no winget): an x64 build of another supported minor
+ # still runs the wheels ARM64 cannot, so take it over the native interpreter.
+ return (Find-CompatiblePython -X64Only)
+ }
+
# ── Install Python if no compatible version (3.11-3.13) found ──
# Find-CompatiblePython returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null.
Write-TauriLog "STEP" "Installing Python"
@@ -1318,6 +1423,26 @@ exit 0
return (Exit-InstallFailure "Python installation failed")
}
}
+ # ── Windows on ARM: swap a native ARM64 interpreter for x64 ──
+ # pyarrow and hf-transfer publish no win_arm64 wheel, so an ARM64 Python source-builds
+ # both and fails deep into the run. Warn up front if x64 is unobtainable.
+ if ($DetectedPython -and (Get-HostMachineArch) -eq "arm64" -and $DetectedPython.Arch -ne "x86_64") {
+ substep "windows on arm: only a native ARM64 Python $($DetectedPython.Version) was found." "Yellow"
+ substep "pyarrow and hf-transfer publish no win_arm64 wheels, so installing x64 Python..." "Yellow"
+ $X64Python = Install-X64Python
+ if ($X64Python) {
+ $DetectedPython = $X64Python
+ step "python" "using x64 Python $($DetectedPython.Version) under emulation"
+ } else {
+ Write-Host "[WARN] Could not install an x64 Python on this ARM64 machine." -ForegroundColor Yellow
+ Write-Host " Continuing with ARM64 Python $($DetectedPython.Version), but the install is likely to fail:" -ForegroundColor Yellow
+ Write-Host " pyarrow (via datasets) and hf-transfer ship no win_arm64 wheels and will be" -ForegroundColor Yellow
+ Write-Host " built from source, which needs CMake plus the MSVC and Rust toolchains." -ForegroundColor Yellow
+ Write-Host " Fix: install x64 Python from https://www.python.org/downloads/windows/" -ForegroundColor Yellow
+ Write-Host " (choose 'Windows installer (64-bit)', not ARM64), then re-run this installer." -ForegroundColor Yellow
+ }
+ }
+
$DiagPythonVersion = $PythonVersion
if ($DetectedPython) { $DiagPythonVersion = $DetectedPython.Version }
$InitialGpuBranch = "unknown"
@@ -2438,6 +2563,13 @@ exit 0
}
} else {
Write-TauriLog "STEP" "Installing PyTorch"
+ # Windows on ARM lacks only torchaudio (whl/cpu win_arm64: torch 42,
+ # torchvision 60, torchaudio 0), so drop that pin instead of aborting. Ask the
+ # interpreter, not PROCESSOR_ARCHITECTURE; reached when no x64 Python exists.
+ $VenvPlatform = ""
+ try {
+ $VenvPlatform = (& $VenvPython -c "import sysconfig; print(sysconfig.get_platform())" 2>$null | Out-String).Trim().ToLowerInvariant()
+ } catch { $VenvPlatform = "" }
substep "installing PyTorch ($(Remove-IndexUrlCredentials $TorchIndexUrl))..."
# Bound the companions to the capped torch on EVERY index, cu
# families included: torchaudio 2.11 dropped its exact torch pin from
@@ -2445,7 +2577,13 @@ exit 0
# resolve a mismatched 2.11.0 build. Mirrors install.sh.
$_pinVisionSpec = "torchvision>=0.19,<0.26.0"
$_pinAudioSpec = "torchaudio>=2.4,<2.11.0"
- $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" $_pinVisionSpec $_pinAudioSpec --default-index $TorchIndexUrl }
+ $_torchSpecs = @("torch>=2.4,<2.11.0", $_pinVisionSpec, $_pinAudioSpec)
+ if ($VenvPlatform -eq "win-arm64") {
+ substep "windows on arm: skipping torchaudio (upstream publishes no"
+ substep "win_arm64 wheel); torch and torchvision install normally."
+ $_torchSpecs = @("torch>=2.4,<2.11.0", $_pinVisionSpec)
+ }
+ $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython @_torchSpecs --default-index $TorchIndexUrl }
if ($torchInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
diff --git a/pyproject.toml b/pyproject.toml
index ce19d21399..8895bf0686 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,9 +47,14 @@ version = {attr = "unsloth.models._utils.__version__"}
[tool.setuptools]
include-package-data = true
+[tool.setuptools.cmdclass]
+# Snapshots CHANGELOG.md into studio/ so every build path ships it.
+build_py = "_changelog_build.build_py"
+
[tool.setuptools.package-data]
unsloth_cli = ["codex_fallback_prompt.md", "pi_subagent.ts"]
studio = [
+ "CHANGELOG.md",
"*.sh",
"*.ps1",
"*.bat",
diff --git a/scripts/profile_startup.py b/scripts/profile_startup.py
new file mode 100644
index 0000000000..937d007ac1
--- /dev/null
+++ b/scripts/profile_startup.py
@@ -0,0 +1,377 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Measure where Unsloth Studio's startup time goes, per platform.
+
+Nothing measured this before: the backend logs "lifespan startup completed in X ms"
+but no test or CI job asserted a budget, and studio_test_kit discards the elapsed
+time of its /healthz poll. A first local run (Linux, warm cache, fast server CPU)
+found `import main` alone costs 6.6s before the server can bind, dominated by eager
+module-level imports pulled in by the `routes` package:
+
+ torch 1930 ms self
+ unsloth_zoo 914 ms self
+ routes 779 ms self
+ transformers 524 ms self
+
+Phases measured:
+ import `python -X importtime -c "import main"`, top cumulative + per-package self
+ spawn process start -> first byte on stdout
+ healthz process start -> /api/health (or /healthz) answers 200
+ lifespan the backend's own "lifespan startup completed in X ms" log line
+
+Usage:
+ python scripts/profile_startup.py --repeats 3 --json out.json
+ python scripts/profile_startup.py --import-only # no server, no port needed
+
+Exit code is 0 unless --max-healthz-seconds is given and exceeded.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import os
+import platform
+import re
+import shutil
+import socket
+import statistics
+import subprocess
+import sys
+import threading
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+BACKEND = REPO_ROOT / "studio" / "backend"
+
+_IMPORTTIME_RE = re.compile(r"import time:\s+(\d+)\s+\|\s+(\d+)\s+\|(\s*)(\S.*)")
+
+
+def _free_port() -> int:
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return int(s.getsockname()[1])
+
+
+def profile_imports(python: str, top: int = 15) -> dict:
+ """Cumulative and self import cost for the backend's module graph.
+
+ Run in a subprocess with -X importtime: the numbers are only meaningful for a
+ cold interpreter, and importing in-process would measure a warm sys.modules.
+ """
+ proc = subprocess.run(
+ [python, "-X", "importtime", "-c", "import sys; sys.path.insert(0, '.'); import main"],
+ cwd = BACKEND,
+ capture_output = True,
+ text = True,
+ timeout = 900,
+ )
+ rows = []
+ for line in proc.stderr.splitlines():
+ m = _IMPORTTIME_RE.match(line)
+ if m:
+ rows.append((int(m.group(1)), int(m.group(2)), m.group(4).strip()))
+ if not rows:
+ return {"ok": False, "error": (proc.stderr or proc.stdout)[-2000:]}
+ if proc.returncode != 0:
+ # Rows survive up to the failure, so any total from a partial graph is wrong.
+ return {
+ "ok": False,
+ "error": (proc.stderr or proc.stdout)[-2000:],
+ "partial_rows": len(rows),
+ }
+
+ by_cum = sorted(rows, key = lambda r: -r[1])
+ # Total comes from the `main` row, not by_cum[0]: -X importtime also prints the
+ # interpreter's own startup graph (`site`), which can outrank a trivial main.
+ main_row = next((r for r in reversed(rows) if r[2] == "main"), None)
+ if main_row is None:
+ return {
+ "ok": False,
+ "error": "no `import main` row in -X importtime output\n"
+ + (proc.stderr or proc.stdout)[-2000:],
+ }
+ self_by_pkg: dict[str, int] = {}
+ for self_us, _cum, name in rows:
+ pkg = name.split(".")[0]
+ self_by_pkg[pkg] = self_by_pkg.get(pkg, 0) + self_us
+
+ return {
+ "ok": True,
+ "total_seconds": round(main_row[1] / 1e6, 3),
+ "top_cumulative": [
+ {"module": n, "seconds": round(c / 1e6, 3)} for _s, c, n in by_cum[:top]
+ ],
+ "self_by_package_ms": {
+ k: round(v / 1000) for k, v in sorted(self_by_pkg.items(), key = lambda x: -x[1])[:top]
+ },
+ }
+
+
+def _terminate_tree(proc: subprocess.Popen) -> None:
+ """Stop the server AND its children, which on Windows are a separate process.
+
+ CI profiles `Scripts/unsloth.exe`, a distlib launcher stub that CreateProcess's
+ the venv python and waits, so terminate() reaps the stub only: the real backend
+ keeps the inherited stdout handle, the reader thread never sees EOF, and
+ --repeats strands one server per iteration on the shared UNSLOTH_STUDIO_HOME.
+ taskkill /T walks the tree, as unsloth_cli/commands/start.py already does.
+ """
+ if proc.poll() is not None:
+ return
+ if os.name == "nt":
+ try:
+ killed = subprocess.run(
+ ["taskkill", "/PID", str(proc.pid), "/T", "/F"],
+ capture_output = True,
+ timeout = 30,
+ check = False,
+ )
+ if killed.returncode == 0:
+ return
+ except Exception:
+ # taskkill missing or timed out; fall through so the stub still dies.
+ pass
+ # check=False: a nonzero taskkill does not raise, so fall through as well.
+ proc.terminate()
+
+
+def profile_launch(
+ bin_path: str,
+ port: int,
+ timeout_s: int = 300,
+) -> dict:
+ """Spawn the backend the way the desktop app does and time it to first 200."""
+ log_lines: list[str] = []
+ first_byte: list[float] = []
+ t0 = time.perf_counter()
+ proc = subprocess.Popen(
+ [bin_path, "studio", "--api-only", "-H", "127.0.0.1", "-p", str(port)],
+ cwd = REPO_ROOT,
+ stdout = subprocess.PIPE,
+ stderr = subprocess.STDOUT,
+ text = True,
+ bufsize = 1,
+ )
+
+ def _drain() -> None:
+ # Runs alongside the health polling: the first read timestamps the spawn
+ # phase, and an undrained pipe blocks the backend before it binds.
+ for line in proc.stdout:
+ if not first_byte:
+ first_byte.append(time.perf_counter() - t0)
+ log_lines.append(line.rstrip("\n"))
+
+ reader = threading.Thread(target = _drain, daemon = True)
+ reader.start()
+
+ t_healthz = None
+ deadline = t0 + timeout_s
+ try:
+ while time.perf_counter() < deadline:
+ if proc.poll() is not None:
+ break
+ if t_healthz is None:
+ for url in (
+ f"http://127.0.0.1:{port}/api/health",
+ f"http://127.0.0.1:{port}/healthz",
+ ):
+ try:
+ with urllib.request.urlopen(url, timeout = 2) as r:
+ if r.status == 200:
+ t_healthz = time.perf_counter() - t0
+ break
+ except (urllib.error.URLError, OSError, TimeoutError):
+ pass
+ if t_healthz is not None:
+ break
+ time.sleep(0.25)
+ finally:
+ _terminate_tree(proc)
+ try:
+ # Safe: the reader drains the pipe, so the child cannot block on write().
+ proc.wait(timeout = 30)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ reader.join(timeout = 10)
+
+ t_first_byte = first_byte[0] if first_byte else None
+ lifespan_ms = None
+ for line in log_lines:
+ m = re.search(r"lifespan startup completed in ([\d.]+)ms", line)
+ if m:
+ lifespan_ms = float(m.group(1))
+ return {
+ "spawn_seconds": round(t_first_byte, 3) if t_first_byte is not None else None,
+ "healthz_seconds": round(t_healthz, 3) if t_healthz is not None else None,
+ "lifespan_ms": lifespan_ms,
+ "reached_healthz": t_healthz is not None,
+ "log_tail": log_lines[-25:],
+ }
+
+
+def python_version_of(python: str) -> str:
+ """Version of the interpreter that runs the imports, not the one running us.
+
+ --python points at the installed Studio venv while this script runs under the
+ runner's system python, so platform.python_version() would label it wrong.
+ """
+ if python == sys.executable:
+ return platform.python_version()
+ try:
+ proc = subprocess.run(
+ [python, "-c", "import platform; print(platform.python_version())"],
+ capture_output = True,
+ text = True,
+ timeout = 60,
+ )
+ if proc.returncode == 0 and proc.stdout.strip():
+ return proc.stdout.strip()
+ except (OSError, subprocess.SubprocessError):
+ pass
+ return "unknown"
+
+
+def find_bin() -> str | None:
+ home = os.environ.get("UNSLOTH_STUDIO_HOME") or str(Path.home() / ".unsloth" / "studio")
+ names = ["unsloth.exe", "unsloth"] if platform.system() == "Windows" else ["unsloth"]
+ subdirs = ["unsloth_studio/Scripts", "unsloth_studio/bin", "bin", "Scripts"]
+ for sd in subdirs:
+ for n in names:
+ p = Path(home) / sd / n
+ if p.exists():
+ return str(p)
+ return shutil.which("unsloth")
+
+
+def main(argv: list[str]) -> int:
+ ap = argparse.ArgumentParser(
+ description = __doc__, formatter_class = argparse.RawDescriptionHelpFormatter
+ )
+ ap.add_argument(
+ "--repeats",
+ type = int,
+ default = 1,
+ help = "launch repeats; the median is reported (imports are measured once)",
+ )
+ ap.add_argument(
+ "--python",
+ default = sys.executable,
+ help = "interpreter used for the import profile (default: this one)",
+ )
+ ap.add_argument("--bin", help = "path to the unsloth CLI (default: autodetect)")
+ ap.add_argument(
+ "--import-only",
+ action = "store_true",
+ help = "skip the server phases (no install needed beyond the deps)",
+ )
+ ap.add_argument(
+ "--max-healthz-seconds",
+ type = float,
+ help = "fail if the median time to a healthy port exceeds this",
+ )
+ ap.add_argument("--json", help = "write the full report here")
+ a = ap.parse_args(argv)
+ # range(0) launches nothing, leaving the budget check with nothing to fail on.
+ if a.repeats < 1:
+ ap.error("--repeats must be at least 1")
+ # Same reason: --import-only never launches anything.
+ if a.import_only and a.max_healthz_seconds is not None:
+ ap.error("--max-healthz-seconds cannot be combined with --import-only")
+ # nan and inf parse fine as floats but `med > budget` is then always False,
+ # so the gate would report success without ever bounding anything.
+ if a.max_healthz_seconds is not None and not math.isfinite(a.max_healthz_seconds):
+ ap.error("--max-healthz-seconds must be a finite number")
+
+ report: dict = {
+ "platform": platform.system().lower(),
+ "machine": platform.machine(),
+ "python": python_version_of(a.python),
+ "cpu_count": os.cpu_count(),
+ }
+
+ print("== import graph ==")
+ report["imports"] = profile_imports(a.python)
+ imp = report["imports"]
+ if imp.get("ok"):
+ print(f" import main: {imp['total_seconds']}s")
+ for row in imp["top_cumulative"][:8]:
+ print(f" {row['seconds']:7.3f}s {row['module']}")
+ print(" self time by package (ms):")
+ for k, v in list(imp["self_by_package_ms"].items())[:8]:
+ print(f" {v:8} ms {k}")
+ else:
+ print(f" FAILED: {imp.get('error', '')[:400]}")
+
+ if not a.import_only:
+ bin_path = a.bin or find_bin()
+ if not bin_path:
+ print(
+ "== launch == skipped: no unsloth CLI found "
+ "(set UNSLOTH_STUDIO_HOME or pass --bin)"
+ )
+ report["launch"] = {"skipped": "no unsloth CLI found"}
+ else:
+ print(f"== launch == {bin_path}")
+ runs = []
+ for i in range(a.repeats):
+ r = profile_launch(bin_path, _free_port())
+ runs.append(r)
+ print(
+ f" run {i + 1}: healthz={r['healthz_seconds']}s "
+ f"lifespan={r['lifespan_ms']}ms reached={r['reached_healthz']}"
+ )
+ got = [r["healthz_seconds"] for r in runs if r["healthz_seconds"] is not None]
+ report["launch"] = {
+ "runs": runs,
+ "failed_runs": sum(1 for r in runs if not r["reached_healthz"]),
+ "healthz_median_seconds": round(statistics.median(got), 3) if got else None,
+ "healthz_max_seconds": round(max(got), 3) if got else None,
+ }
+ if got:
+ print(
+ f" median time to healthy port: {report['launch']['healthz_median_seconds']}s"
+ )
+
+ if a.json:
+ Path(a.json).write_text(json.dumps(report, indent = 2), encoding = "utf-8")
+ print(f"\nwrote {a.json}")
+
+ if a.max_healthz_seconds is not None:
+ launch = report.get("launch") or {}
+ med = launch.get("healthz_median_seconds")
+ failed = launch.get("failed_runs") or 0
+ if failed:
+ # Failed launches fail the budget; dropping them would keep only the fast ones.
+ print(
+ f"::error::startup regression: {failed} of {len(launch.get('runs') or [])} "
+ f"launches never became healthy within the timeout"
+ )
+ return 1
+ if med is None:
+ # Nothing measured: exiting 0 would pass a requested budget without a
+ # single health request, so fail closed.
+ print(
+ "::error::startup regression: no healthz measurement, so the "
+ f"{a.max_healthz_seconds}s budget was never checked "
+ f"({launch.get('skipped') or 'launch phase produced no runs'})"
+ )
+ return 1
+ elif med > a.max_healthz_seconds:
+ print(
+ f"::error::startup regression: {med}s median to a healthy port "
+ f"exceeds the {a.max_healthz_seconds}s budget"
+ )
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/studio/backend/auth/storage.py b/studio/backend/auth/storage.py
index 5f80ad89a3..35135b21eb 100644
--- a/studio/backend/auth/storage.py
+++ b/studio/backend/auth/storage.py
@@ -76,7 +76,13 @@ def _load_bootstrap_password() -> Optional[str]:
global _bootstrap_password
_bootstrap_password = None
if _BOOTSTRAP_PW_PATH.is_file():
- bootstrap_password = _BOOTSTRAP_PW_PATH.read_text(encoding = "utf-8").strip()
+ # No caller handles a raise, so an unreadable file has to mean "no bootstrap
+ # password", not a dead backend. We write UTF-8, so bytes that will not
+ # decode are damage whose plaintext is worthless anyway.
+ try:
+ bootstrap_password = _BOOTSTRAP_PW_PATH.read_text(encoding = "utf-8").strip()
+ except (OSError, UnicodeDecodeError):
+ return _bootstrap_password
if bootstrap_password:
_bootstrap_password = bootstrap_password
return _bootstrap_password
diff --git a/studio/backend/cloudflare_tunnel.py b/studio/backend/cloudflare_tunnel.py
index 78fce0c70a..f7967e2faa 100644
--- a/studio/backend/cloudflare_tunnel.py
+++ b/studio/backend/cloudflare_tunnel.py
@@ -310,6 +310,7 @@ class CloudflareTunnel:
stderr = subprocess.STDOUT,
stdin = subprocess.DEVNULL,
text = True,
+ encoding = "utf-8",
errors = "replace",
bufsize = 1,
**_windows_hidden_kwargs(),
diff --git a/studio/backend/core/data_recipe/local_callable_validators.py b/studio/backend/core/data_recipe/local_callable_validators.py
index ffc81669ae..143895d781 100644
--- a/studio/backend/core/data_recipe/local_callable_validators.py
+++ b/studio/backend/core/data_recipe/local_callable_validators.py
@@ -257,6 +257,8 @@ def _run_oxc_batch(
cwd = str(_OXC_TOOL_DIR),
input = json.dumps(payload),
text = True,
+ encoding = "utf-8",
+ errors = "replace",
capture_output = True,
check = False,
env = env,
diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py
index 0af37e627f..e78bf1be8d 100644
--- a/studio/backend/core/inference/inference.py
+++ b/studio/backend/core/inference/inference.py
@@ -567,7 +567,7 @@ class InferenceBackend:
_meta_path = Path(config.path) / "export_metadata.json"
try:
if _meta_path.exists():
- _meta = json.loads(_meta_path.read_text(encoding = "utf-8"))
+ _meta = json.loads(_meta_path.read_text(encoding = "utf-8-sig"))
if _meta.get("base_model"):
processor_source = _meta["base_model"]
except Exception:
diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py
index 144aa1fd37..dcfbfb3338 100644
--- a/studio/backend/core/inference/llama_cpp.py
+++ b/studio/backend/core/inference/llama_cpp.py
@@ -85,6 +85,7 @@ from core.tool_healing import (
strip_outside_think,
)
from utils.native_path_leases import child_env_without_native_path_secret
+from utils.child_stdio import utf8_child_env
from utils.hf_xet_fallback import hf_hub_download_with_xet_fallback
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
@@ -581,7 +582,7 @@ def _load_swa_cache() -> dict:
if _SWA_CACHE is not None:
return _SWA_CACHE
try:
- with open(_swa_cache_path(), encoding = "utf-8") as f:
+ with open(_swa_cache_path(), encoding = "utf-8-sig") as f:
_SWA_CACHE = json.load(f)
if not isinstance(_SWA_CACHE, dict):
_SWA_CACHE = {}
@@ -632,7 +633,7 @@ def _fetch_swa_entry_from_hf(repo_id: str) -> Optional[object]:
repo_type = "model",
cache_dir = active_hf_hub_cache(),
)
- with open(cfg_path, encoding = "utf-8") as f:
+ with open(cfg_path, encoding = "utf-8-sig") as f:
cfg = json.load(f)
except Exception:
return None
@@ -3046,6 +3047,7 @@ class LlamaCppBackend:
[bin_path, "--help"],
capture_output = True,
text = True,
+ encoding = "utf-8",
errors = "replace",
timeout = 10,
check = False,
@@ -3618,6 +3620,8 @@ class LlamaCppBackend:
],
capture_output = True,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = 10,
env = child_env_without_native_path_secret(),
**_windows_hidden_subprocess_kwargs(),
@@ -3732,7 +3736,7 @@ class LlamaCppBackend:
encoding = "utf-8",
errors = "replace",
timeout = 15,
- env = env,
+ env = utf8_child_env(env),
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode != 0:
@@ -5482,7 +5486,9 @@ class LlamaCppBackend:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
- env = env,
+ encoding = "utf-8",
+ errors = "replace",
+ env = utf8_child_env(env),
**_windows_hidden_subprocess_kwargs(),
**_child_popen_kwargs(),
)
@@ -6696,6 +6702,8 @@ class LlamaCppBackend:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
env = env,
**_windows_hidden_subprocess_kwargs(),
**_child_popen_kwargs(),
@@ -8712,6 +8720,8 @@ class LlamaCppBackend:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
env = env,
**_windows_hidden_subprocess_kwargs(),
**_child_popen_kwargs(),
@@ -10214,6 +10224,8 @@ class LlamaCppBackend:
["pgrep", "-a", "-f", "llama-server"],
capture_output = True,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = 5,
env = child_env_without_native_path_secret(),
)
diff --git a/studio/backend/core/inference/worker.py b/studio/backend/core/inference/worker.py
index 3f32b3bd57..f208183300 100644
--- a/studio/backend/core/inference/worker.py
+++ b/studio/backend/core/inference/worker.py
@@ -151,7 +151,7 @@ def _resolve_lora_4bit(mc, load_in_4bit: bool) -> bool:
import json
try:
- with open(adapter_cfg_path, encoding = "utf-8") as f:
+ with open(adapter_cfg_path, encoding = "utf-8-sig") as f:
adapter_cfg = json.load(f)
training_method = adapter_cfg.get("unsloth_training_method")
if training_method == "lora" and load_in_4bit:
@@ -963,7 +963,7 @@ def run_inference_process(
if _local_adapter_cfg.is_file():
try:
_lora_base = (
- _json.loads(_local_adapter_cfg.read_text(encoding = "utf-8")).get(
+ _json.loads(_local_adapter_cfg.read_text(encoding = "utf-8-sig")).get(
"base_model_name_or_path"
)
or None
diff --git a/studio/backend/core/rag/embed_llama_server.py b/studio/backend/core/rag/embed_llama_server.py
index facd989b27..b3ac62e520 100644
--- a/studio/backend/core/rag/embed_llama_server.py
+++ b/studio/backend/core/rag/embed_llama_server.py
@@ -103,6 +103,8 @@ class LlamaServerBackend:
[binary, "--help"],
capture_output = True,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = 30,
**windows_hidden_subprocess_kwargs(),
)
@@ -331,6 +333,8 @@ class LlamaServerBackend:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
env = env,
**windows_hidden_subprocess_kwargs(),
**child_popen_kwargs(),
diff --git a/studio/backend/core/rag/embeddings.py b/studio/backend/core/rag/embeddings.py
index c86c0d3c51..95b8a866b2 100644
--- a/studio/backend/core/rag/embeddings.py
+++ b/studio/backend/core/rag/embeddings.py
@@ -100,7 +100,7 @@ def _st_module_subdirs(name: str, token: str | None) -> tuple[str, ...]:
path = Path(normalize_path(name)).expanduser() / "modules.json"
if not path.is_file():
return ()
- data = json.loads(path.read_text(encoding = "utf-8"))
+ data = json.loads(path.read_text(encoding = "utf-8-sig"))
else:
from huggingface_hub import hf_hub_download
from huggingface_hub.utils import EntryNotFoundError
@@ -115,7 +115,7 @@ def _st_module_subdirs(name: str, token: str | None) -> tuple[str, ...]:
)
except EntryNotFoundError:
return ()
- data = json.loads(open(local, encoding = "utf-8").read())
+ data = json.loads(open(local, encoding = "utf-8-sig").read())
subdirs = []
for module in data or ():
sub = str((module or {}).get("path", "")).strip().strip("/")
diff --git a/studio/backend/core/training/worker.py b/studio/backend/core/training/worker.py
index baf6329dae..b5fb5d224e 100644
--- a/studio/backend/core/training/worker.py
+++ b/studio/backend/core/training/worker.py
@@ -43,6 +43,7 @@ if sys.platform.startswith("linux") and "HSA_ENABLE_DXG_DETECTION" not in os.env
pass
logger = get_logger(__name__)
+from utils.child_stdio import utf8_child_env
from utils.hardware import apply_gpu_ids
from utils.training_runs import build_default_output_dir_name
from utils.wheel_utils import (
@@ -385,6 +386,10 @@ def _install_package_wheel_first(
"stdout": _sp.PIPE,
"stderr": _sp.STDOUT,
"text": True,
+ "encoding": "utf-8",
+ "errors": "replace",
+ # Make the Python child emit the UTF-8 we decode above.
+ "env": utf8_child_env(),
}
if is_hip:
_run_kwargs["timeout"] = 1800
@@ -606,6 +611,9 @@ def _ensure_flash_linear_attention_unconditional(event_queue: Any) -> bool:
stdout = _sp.PIPE,
stderr = _sp.STDOUT,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
+ env = utf8_child_env(),
timeout = _TILELANG_INSTALL_TIMEOUT_S,
)
except _sp.TimeoutExpired:
@@ -849,6 +857,9 @@ def _run_pip(cmd: list[str], event_queue: Any, label: str) -> bool:
stdout = _sp.PIPE,
stderr = _sp.STDOUT,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
+ env = utf8_child_env(),
timeout = _TILELANG_INSTALL_TIMEOUT_S,
)
except _sp.TimeoutExpired:
diff --git a/studio/backend/hub/services/models/ollama.py b/studio/backend/hub/services/models/ollama.py
index 56275c22a9..da30f7e98c 100644
--- a/studio/backend/hub/services/models/ollama.py
+++ b/studio/backend/hub/services/models/ollama.py
@@ -215,7 +215,7 @@ def _ollama_model_info_from_manifest(
return None
try:
- manifest = json.loads(tag_file.read_text(encoding = "utf-8"))
+ manifest = json.loads(tag_file.read_text(encoding = "utf-8-sig"))
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
logger.debug("Skipping unreadable/invalid Ollama manifest %s: %s", tag_file, e)
return None
@@ -228,7 +228,7 @@ def _ollama_model_info_from_manifest(
config_blob = _ollama_blob_path(blobs_dir, config_digest)
if config_blob is not None and _safe_is_file(config_blob):
try:
- cfg = json.loads(config_blob.read_text(encoding = "utf-8"))
+ cfg = json.loads(config_blob.read_text(encoding = "utf-8-sig"))
model_type = cfg.get("model_type", "")
file_type = cfg.get("file_type", "")
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
diff --git a/studio/backend/hub/utils/download_registry.py b/studio/backend/hub/utils/download_registry.py
index 39c27208b1..760ef6b01c 100644
--- a/studio/backend/hub/utils/download_registry.py
+++ b/studio/backend/hub/utils/download_registry.py
@@ -464,6 +464,8 @@ def _read_marker_value(marker: Path) -> Optional[str]:
return None
value = marker.read_text(encoding = "utf-8").strip()
except (OSError, UnicodeDecodeError):
+ # UnicodeDecodeError is a ValueError, so it would escape and abort
+ # prepare_cache_for_transport. An unknown value just purges and restarts.
return None
return value if value in VALID_TRANSPORTS else None
diff --git a/studio/backend/loggers/config.py b/studio/backend/loggers/config.py
index 688d3c7ebe..57cf7cecd6 100644
--- a/studio/backend/loggers/config.py
+++ b/studio/backend/loggers/config.py
@@ -42,8 +42,12 @@ class LogConfig:
log_level_name = os.getenv("LOG_LEVEL", "INFO").upper()
log_level = getattr(logging, log_level_name, logging.INFO)
- if sys.platform == "win32":
- for stream in (sys.stdout, sys.stderr):
+ # Non-ASCII on a non-UTF-8 stream raises UnicodeEncodeError (Windows,
+ # LANG=C), so key off the stream, not the platform.
+ for stream in (sys.stdout, sys.stderr):
+ if getattr(stream, "encoding", "") and not str(stream.encoding).lower().replace(
+ "-", ""
+ ).startswith("utf8"):
if hasattr(stream, "reconfigure"):
try:
stream.reconfigure(encoding = "utf-8", errors = "replace")
diff --git a/studio/backend/main.py b/studio/backend/main.py
index 02f5a20106..9a2e598314 100644
--- a/studio/backend/main.py
+++ b/studio/backend/main.py
@@ -347,6 +347,7 @@ from utils.update_status import (
get_studio_install_source_status,
get_studio_update_status,
)
+from utils.changelog import get_release_notes, is_supported_version_query
from utils.studio_version import get_studio_version
from utils.api_errors import install_api_error_handlers
@@ -1154,6 +1155,18 @@ def studio_update_status(_current_subject: str = Depends(get_current_subject)):
return get_studio_update_status(UNSLOTH_VERSION)
+@app.get("/api/studio/release-notes")
+def studio_release_notes(
+ version: str = Query(..., max_length = 64),
+ refresh: bool = Query(False),
+ _current_subject: str = Depends(get_current_subject),
+):
+ """Return CHANGELOG.md notes for exactly `version` (never a nearby one)."""
+ if not is_supported_version_query(version):
+ raise HTTPException(status_code = 422, detail = "Invalid version.")
+ return get_release_notes(version, refresh = refresh)
+
+
@app.get(
"/api/studio/download-transport-capabilities",
response_model = TransportCapabilities,
diff --git a/studio/backend/plugins/data-designer-github-repo-seed/src/data_designer_github_repo_seed/scraper_impl/state_store.py b/studio/backend/plugins/data-designer-github-repo-seed/src/data_designer_github_repo_seed/scraper_impl/state_store.py
index b4c226136b..b059fad7ff 100644
--- a/studio/backend/plugins/data-designer-github-repo-seed/src/data_designer_github_repo_seed/scraper_impl/state_store.py
+++ b/studio/backend/plugins/data-designer-github-repo-seed/src/data_designer_github_repo_seed/scraper_impl/state_store.py
@@ -6,10 +6,93 @@
from __future__ import annotations
import json
+import locale
import os
import threading
from pathlib import Path
-from typing import Any, Dict
+from typing import Any, Dict, NamedTuple
+
+
+def _locale_encoding() -> str:
+ """The codepage a pre-UTF-8 release here would have written, or "".
+
+ Empty on a UTF-8 host, where there is no codepage to attribute the file to.
+ """
+ try:
+ preferred = locale.getencoding()
+ except AttributeError: # Python < 3.11
+ preferred = locale.getpreferredencoding(False)
+ if preferred.lower().replace("-", "").replace("_", "") == "utf8":
+ return ""
+ return preferred
+
+
+# Trail bytes can land on JSON punctuation, so a single-byte fallback misreads these.
+_DOUBLE_BYTE_ENCODINGS = ("cp932", "cp936", "cp949", "cp950")
+
+
+def _parse(raw: bytes, encoding: str) -> Any:
+ """Parse one JSON document under *encoding*, or None if it does not.
+
+ RecursionError is a RuntimeError, so nesting json.loads will not descend is
+ the one parse failure the other three miss. Both callers run this outside
+ any further handler, so it has to answer None here or a single damaged
+ record aborts the scraper at startup instead of being skipped.
+ """
+ try:
+ return json.loads(raw.decode(encoding))
+ except (UnicodeDecodeError, LookupError, ValueError, RecursionError):
+ return None
+
+
+class _Reading(NamedTuple):
+ as_utf8: Any
+ as_legacy: Any
+
+
+def _read_line(raw: bytes, codepage: str) -> _Reading:
+ """Read one line as UTF-8 and as a codepage, for dedup keys only.
+
+ Requiring valid JSON, not merely a successful decode, is what separates a
+ genuine legacy record from a half-written UTF-8 one: a torn multibyte
+ character decodes under cp1252 but leaves the JSON unterminated. Some byte
+ strings parse both ways, e.g. cp1251 ``Р°`` is ``D0 B0``, which is also
+ UTF-8 ``а``.
+
+ The codepage reading is never authoritative, because the file's own encoding
+ cannot be recovered from its bytes. Reading a cp1251 shard on a cp1252
+ machine turns ``Привет`` into ``Ïðèâåò`` and every byte of it decodes
+ cleanly, so a successful decode proves nothing about who wrote it. It is
+ used only to recover the dedup keys, which are ASCII ids and come back the
+ same under any of these, so the first reading that parses will do.
+
+ That is also why several are tried. latin-1 alone mangles the double-byte
+ codepages: cp932 ``表`` is ``95 5C``, and latin-1 turns the trail byte into
+ a JSON backslash, so the record fails to parse and its id is forgotten.
+ """
+ as_utf8 = _parse(raw, "utf-8")
+ # A record that reads as UTF-8 needs no second reading: re-parsing cost 2.8x on a
+ # 76 MB shard, and these reach gigabytes. Only a dict, since key lookup falls
+ # through to the codepage when UTF-8 yields none.
+ if isinstance(as_utf8, dict):
+ return _Reading(as_utf8, None)
+ for encoding in (codepage, "latin-1", *_DOUBLE_BYTE_ENCODINGS):
+ if not encoding:
+ continue
+ as_legacy = _parse(raw, encoding)
+ if as_legacy is not None:
+ return _Reading(as_utf8, as_legacy)
+ return _Reading(as_utf8, None)
+
+
+class _Scan(NamedTuple):
+ """What a pass over an existing shard established about it."""
+
+ legacy: bool # enough evidence to trust the codepage reading's keys
+ readable: bool
+ saw_non_ascii: bool # some line's meaning depends on the encoding
+ utf8_keys: set # keys from lines UTF-8 could read
+ legacy_keys: set # keys only the codepage reading yields
class StateStore:
@@ -18,12 +101,19 @@ class StateStore:
self.path.parent.mkdir(parents = True, exist_ok = True)
self._lock = threading.Lock()
self._data: Dict[str, Any] = {}
+ # Read whole, and UTF-8 only unlike the shards below: a checkpoint holds
+ # nothing but base64 cursors and booleans, so a codepage retry could only ever
+ # add non-ASCII. That would resume on a mojibaked cursor, which GitHub rejects
+ # with INVALID_CURSOR_ARGUMENTS, and the empty page it returns marks the stream
+ # done and skips the rest for good. Dropping a damaged checkpoint re-scrapes
+ # from the first page, which the writers dedup.
if self.path.exists():
try:
- with self.path.open(encoding = "utf-8") as f:
- self._data = json.load(f)
- except Exception:
- self._data = {}
+ raw = self.path.read_bytes()
+ except OSError:
+ raw = b""
+ data = _parse(raw, "utf-8")
+ self._data = data if isinstance(data, dict) else {}
def get(
self,
@@ -63,24 +153,83 @@ class JsonlWriter:
self.path = Path(path)
self.path.parent.mkdir(parents = True, exist_ok = True)
self._lock = threading.Lock()
- self._fh = self.path.open("a", buffering = 1, encoding = "utf-8")
self._count_seen_keys: set[str] = set()
- # Preload seen keys for dedup across resumes
+ self._codepage = _locale_encoding()
+ self._ensure_ascii = False
+ encoding = "utf-8"
if self.path.exists() and self.path.stat().st_size > 0:
- try:
- # No guess is safe for a file an older build wrote in the
- # operator's locale, so read past whatever will not decode.
- with self.path.open(encoding = "utf-8", errors = "replace") as f:
- for line in f:
- try:
- obj = json.loads(line)
- k = self._key(obj)
- if k is not None:
- self._count_seen_keys.add(k)
- except Exception:
- pass
- except Exception:
- pass
+ scan = self._scan_existing()
+ self._count_seen_keys = scan.utf8_keys
+ if scan.legacy:
+ self._count_seen_keys |= scan.legacy_keys
+ if scan.saw_non_ascii or not scan.readable:
+ # Never convert: the writing encoding is unrecoverable and guessing
+ # mojibakes the records. Pure ASCII appends store identically under
+ # every codepage, and json.loads turns the \uXXXX escapes back.
+ encoding = "ascii"
+ self._ensure_ascii = True
+ self._fh = self.path.open("a", buffering = 1, encoding = encoding, errors = "strict")
+
+ def _scan_existing(self) -> _Scan:
+ """Read the shard once to recover dedup keys and judge its encoding.
+
+ Line by line: these shards reach gigabytes on a large scrape, so neither
+ the bytes nor the decoded text are held whole.
+
+ The verdict weighs the whole file. Each line with non-ASCII bytes votes:
+ one that parses only under the codepage is evidence of a legacy shard,
+ one that parses as UTF-8 is evidence against, since arbitrary codepage
+ text almost never forms valid multibyte UTF-8. A single corrupt byte in
+ a healthy shard therefore cannot outvote the records around it, and a
+ genuinely legacy shard has a legacy vote on every line that carries an
+ umlaut.
+
+ More than one such line is required, because a single one is genuinely
+ undecidable: a legacy record holding one accented character and an ASCII
+ record holding one stray byte are the same shape. Reading it as damage
+ risks a duplicate; reading it as legacy marks an unreadable record seen
+ and blocks the retry that would replace it, losing it for good. Only one
+ of those is recoverable.
+
+ The verdict only picks which reading supplies the dedup keys. The file
+ itself is never rewritten either way, so a wrong answer costs at most a
+ duplicate, never a corrupted record.
+ """
+ legacy_votes = 0
+ utf8_votes = 0
+ saw_non_ascii = False
+ utf8_keys: set[str] = set()
+ legacy_keys: set[str] = set()
+ try:
+ with self.path.open("rb") as handle:
+ for raw in handle:
+ line = raw.strip()
+ reading = _read_line(line, self._codepage)
+ # ASCII reads the same everywhere: no vote, no constraint.
+ if not line.isascii():
+ saw_non_ascii = True
+ if reading.as_utf8 is None and reading.as_legacy is not None:
+ legacy_votes += 1
+ elif reading.as_utf8 is not None:
+ utf8_votes += 1
+ # Kept apart so a damaged line does not block its own retry.
+ if isinstance(reading.as_utf8, dict):
+ key = self._key(reading.as_utf8)
+ if key is not None:
+ utf8_keys.add(key)
+ elif isinstance(reading.as_legacy, dict):
+ key = self._key(reading.as_legacy)
+ if key is not None:
+ legacy_keys.add(key)
+ except OSError:
+ return _Scan(False, False, False, utf8_keys, legacy_keys)
+ return _Scan(
+ legacy_votes > 1 and legacy_votes > utf8_votes,
+ True,
+ saw_non_ascii,
+ utf8_keys,
+ legacy_keys,
+ )
def _key(self, obj: dict) -> str | None:
for k in ("id", "node_id", "number", "sha", "url"):
@@ -99,7 +248,7 @@ class JsonlWriter:
return False
if k is not None:
self._count_seen_keys.add(k)
- self._fh.write(json.dumps(obj, default = str, ensure_ascii = False))
+ self._fh.write(json.dumps(obj, default = str, ensure_ascii = self._ensure_ascii))
self._fh.write("\n")
self._fh.flush()
return True
diff --git a/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/impl.py b/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/impl.py
index ce0c88e5bf..825b050e07 100644
--- a/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/impl.py
+++ b/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/impl.py
@@ -30,6 +30,8 @@ class UnstructuredSeedReader(SeedReader[UnstructuredSeedSource]):
meta = json_mod.loads(meta_path.read_text(encoding = "utf-8"))
orig_name = meta.get("original_filename", path_obj.name)
except (json_mod.JSONDecodeError, OSError, UnicodeDecodeError):
+ # Undecodable metadata is as malformed as invalid JSON, so
+ # fall back to the file's own name rather than abort the seed.
pass
file_entries.append((path_obj, orig_name))
diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py
index d0a2d97f74..20a5af1409 100644
--- a/studio/backend/routes/inference.py
+++ b/studio/backend/routes/inference.py
@@ -4434,7 +4434,7 @@ def _effective_load_in_4bit(config: ModelConfig, requested: bool) -> bool:
if not adapter_cfg_path.exists():
return load_in_4bit
try:
- with open(adapter_cfg_path, encoding = "utf-8") as f:
+ with open(adapter_cfg_path, encoding = "utf-8-sig") as f:
adapter_cfg = json.load(f)
if not isinstance(adapter_cfg, dict): # malformed -> keep requested
return load_in_4bit
diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py
index 96c5b96d73..6e587c18e8 100644
--- a/studio/backend/routes/models.py
+++ b/studio/backend/routes/models.py
@@ -722,7 +722,7 @@ def _scan_ollama_dir(ollama_dir: Path, limit: Optional[int] = None) -> List[Loca
stem_hash = hashlib.sha256(manifest_key.encode()).hexdigest()[:10]
try:
- manifest = json.loads(tag_file.read_text(encoding = "utf-8"))
+ manifest = json.loads(tag_file.read_text(encoding = "utf-8-sig"))
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
logger.debug(
"Skipping unreadable/invalid Ollama manifest %s: %s",
@@ -738,7 +738,7 @@ def _scan_ollama_dir(ollama_dir: Path, limit: Optional[int] = None) -> List[Loca
config_blob = blobs_dir / config_digest.replace(":", "-")
if config_blob.is_file():
try:
- cfg = json.loads(config_blob.read_text(encoding = "utf-8"))
+ cfg = json.loads(config_blob.read_text(encoding = "utf-8-sig"))
model_type = cfg.get("model_type", "")
file_type = cfg.get("file_type", "")
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
@@ -1042,7 +1042,7 @@ def _dir_has_downloaded_model(directory: Path, max_entries: int = 4000) -> bool:
if not m.is_file():
continue
try:
- manifest = json.loads(m.read_text(encoding = "utf-8"))
+ manifest = json.loads(m.read_text(encoding = "utf-8-sig"))
except (json.JSONDecodeError, OSError, ValueError):
continue
for layer in manifest.get("layers") or []:
@@ -3360,6 +3360,8 @@ def _wsl_reveal_in_explorer(path: Path) -> bool:
["wslpath", "-w", str(path)],
capture_output = True,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
check = True,
timeout = 10,
).stdout.strip()
diff --git a/studio/backend/run.py b/studio/backend/run.py
index 08d1c5299e..ef372e004e 100644
--- a/studio/backend/run.py
+++ b/studio/backend/run.py
@@ -786,6 +786,8 @@ def _remove_pid_file():
stored = _PID_FILE.read_text(encoding = "utf-8").strip()
if stored == str(os.getpid()):
_PID_FILE.unlink(missing_ok = True)
+ # Runs first in _graceful_shutdown: a corrupt PID file raising here would
+ # abandon the children the rest of that function exists to kill.
except (OSError, UnicodeDecodeError):
pass
diff --git a/studio/backend/tests/test_chat_text_encoding.py b/studio/backend/tests/test_chat_text_encoding.py
new file mode 100644
index 0000000000..64860dab1a
--- /dev/null
+++ b/studio/backend/tests/test_chat_text_encoding.py
@@ -0,0 +1,195 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Model text stays intact when it carries non-ASCII.
+
+``open()`` and ``Path.read_text()`` fall back to ``locale.getencoding()`` when
+no ``encoding`` is passed. On Windows that is the ANSI codepage, not UTF-8, so
+a chat template or model config holding ``ä ö ü → 世`` mojibakes or raises
+``UnicodeDecodeError``. These files are UTF-8, so the reads must say so.
+
+Each fixture writes raw UTF-8 (``ensure_ascii = False``), matching what
+Hugging Face actually ships, rather than ASCII ``\\uXXXX`` escapes.
+"""
+
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+import textwrap
+from pathlib import Path
+
+
+BACKEND_ROOT = Path(__file__).resolve().parent.parent
+
+
+def test_config_json_round_trips_non_ascii(tmp_path: Path) -> None:
+ from utils import transformers_version
+
+ name = "Modell für Grüße 世界"
+ (tmp_path / "config.json").write_text(
+ json.dumps({"model_type": "llama", "_name_or_path": name}, ensure_ascii = False),
+ encoding = "utf-8",
+ )
+ transformers_version._config_json_cache.clear()
+
+ cfg = transformers_version._load_config_json(str(tmp_path))
+
+ assert cfg is not None
+ assert cfg["_name_or_path"] == name
+
+
+def test_tokenizer_config_round_trips_non_ascii_chat_template(tmp_path: Path) -> None:
+ """Chat templates commonly hold ``→`` and smart quotes, which cp1252 mangles."""
+ from utils import transformers_version
+
+ template = "{{ '→ Grüße 世界' }}"
+ (tmp_path / "tokenizer_config.json").write_text(
+ json.dumps(
+ {"tokenizer_class": "TokenizersBackend", "chat_template": template},
+ ensure_ascii = False,
+ ),
+ encoding = "utf-8",
+ )
+ transformers_version._tokenizer_class_cache.clear()
+
+ assert transformers_version._check_tokenizer_config_needs_v5(str(tmp_path)) is True
+
+
+def test_config_json_survives_a_utf8_bom(tmp_path: Path) -> None:
+ """Notepad wrote "UTF-8 with BOM" by default for years, so hand-edited
+ configs on Windows carry one. Plain utf-8 keeps the BOM and json.load then
+ fails on it; utf-8-sig strips it and is identical otherwise."""
+ from utils import transformers_version
+
+ name = "Grüße 世界"
+ (tmp_path / "config.json").write_text(
+ json.dumps({"model_type": "llama", "_name_or_path": name}, ensure_ascii = False),
+ encoding = "utf-8-sig",
+ )
+ transformers_version._config_json_cache.clear()
+
+ cfg = transformers_version._load_config_json(str(tmp_path))
+
+ assert cfg is not None
+ assert cfg["_name_or_path"] == name
+
+
+def test_remote_code_scan_reads_non_ascii_sources(tmp_path: Path) -> None:
+ """A German Windows profile also puts umlauts in the model sources scanned."""
+ from utils.security import remote_code_scan
+
+ source = "# Grüße über Öl\nVALUE = '世界'\n"
+ # newline = "" pins the bytes on disk, so Windows line end translation cannot make the
+ # read back differ by \r. open() because Path.write_text() only grew newline in 3.10.
+ with open(
+ tmp_path / "modeling_custom.py",
+ "w",
+ encoding = "utf-8",
+ newline = "",
+ ) as handle:
+ handle.write(source)
+
+ files = remote_code_scan.repo_remote_code_files(str(tmp_path))
+
+ assert files["modeling_custom.py"] == source
+
+
+def test_model_config_reads_do_not_rely_on_the_locale_encoding(tmp_path: Path) -> None:
+ """The reads above pass anywhere the locale is already UTF-8, which hides
+ the Windows bug on Linux and macOS. ``-X warn_default_encoding`` makes
+ CPython flag any text I/O that falls back to the locale, so this fails on
+ every platform if an ``encoding`` argument goes missing again."""
+ # The readers swallow exceptions, so record the warnings instead of raising.
+ script = textwrap.dedent(
+ f"""
+ import sys, warnings
+ sys.path.insert(0, {str(BACKEND_ROOT)!r})
+ from utils import transformers_version
+
+ target = {str(tmp_path)!r}
+ with warnings.catch_warnings(record = True) as caught:
+ warnings.simplefilter("always")
+ transformers_version._config_json_cache.clear()
+ transformers_version._tokenizer_class_cache.clear()
+ assert transformers_version._load_config_json(target) is not None
+ assert transformers_version._check_tokenizer_config_needs_v5(target) is True
+
+ missing = [str(w.message) for w in caught if w.category is EncodingWarning]
+ if missing:
+ sys.exit("text I/O fell back to the locale encoding: " + "; ".join(missing))
+ """
+ )
+ for name, payload in (
+ ("config.json", {"model_type": "llama", "_name_or_path": "Grüße"}),
+ ("tokenizer_config.json", {"tokenizer_class": "TokenizersBackend"}),
+ ):
+ (tmp_path / name).write_text(json.dumps(payload, ensure_ascii = False), encoding = "utf-8")
+
+ result = subprocess.run(
+ [sys.executable, "-X", "warn_default_encoding", "-c", script],
+ capture_output = True,
+ text = True,
+ encoding = "utf-8",
+ errors = "replace",
+ timeout = 120,
+ )
+
+ assert result.returncode == 0, result.stderr
+
+
+def test_utf8_child_env_round_trips_non_ascii(tmp_path: Path) -> None:
+ """A Python child encodes stdout with its locale unless told otherwise, so
+ reading its pipe as utf-8 needs the child told to emit utf-8."""
+ from utils.child_stdio import utf8_child_env
+
+ payload = "Grüße über Öl → 世界"
+ child = tmp_path / "child.py"
+ child.write_text("import sys\nsys.stdout.write(" + repr(payload) + ")\n", encoding = "utf-8")
+
+ env = utf8_child_env()
+ assert env["PYTHONIOENCODING"] == "utf-8"
+
+ proc = subprocess.run(
+ [sys.executable, str(child)],
+ capture_output = True,
+ text = True,
+ encoding = "utf-8",
+ errors = "replace",
+ env = env,
+ timeout = 120,
+ )
+
+ assert proc.returncode == 0, proc.stderr
+ assert proc.stdout == payload
+
+
+def test_python_children_are_told_to_emit_utf8() -> None:
+ """Any child we decode as utf-8 must also be told to write utf-8, or a
+ cp1252 console silently mangles what it prints."""
+ import ast
+
+ offenders: list[str] = []
+ for path in sorted(BACKEND_ROOT.rglob("*.py")):
+ parts = path.relative_to(BACKEND_ROOT).parts
+ if any(p in ("tests", "node_modules", "plugins", "__pycache__") for p in parts):
+ continue
+ source = path.read_text(encoding = "utf-8")
+ for node in ast.walk(ast.parse(source, filename = str(path))):
+ if not isinstance(node, ast.Call):
+ continue
+ func = node.func
+ if not (isinstance(func, ast.Attribute) and func.attr in ("run", "Popen")):
+ continue
+ segment = ast.get_source_segment(source, node) or ""
+ if "sys.executable" not in segment or 'encoding = "utf-8"' not in segment:
+ continue
+ if "utf8_child_env" in segment or "PYTHONIOENCODING" in segment:
+ continue
+ offenders.append(f"{path.name}:{node.lineno}")
+
+ assert not offenders, (
+ "these spawn a Python child and decode it as utf-8 without setting the "
+ "child's own stdio encoding; wrap env in utf8_child_env():\n " + "\n ".join(offenders)
+ )
diff --git a/studio/backend/tests/test_rocm_multi_gpu_vram_system_wide.py b/studio/backend/tests/test_rocm_multi_gpu_vram_system_wide.py
index bdafdeae9b..db89b02003 100644
--- a/studio/backend/tests/test_rocm_multi_gpu_vram_system_wide.py
+++ b/studio/backend/tests/test_rocm_multi_gpu_vram_system_wide.py
@@ -45,8 +45,20 @@ def _build_structlog_stub():
_maybe_stub("loggers", _build_loggers_stub)
_maybe_stub("structlog", _build_structlog_stub)
+import pytest
+
import utils.hardware.hardware as hw # noqa: E402
+# The DRM/KFD readers below are Linux-only in production: _rocm_linux_amdgpu_cards and
+# _rocm_linux_sysfs_vram_by_pci_gb return early unless platform.system() is "Linux", and
+# _rocm_kfd_gpu_pci_ids only ever globs /sys/class/kfd. Their fake sysfs tree needs PCI
+# addresses like "0000:00:02.0" as directory names and POSIX separators in the paths the
+# readers match; Windows permits neither, so the tree cannot be represented there.
+linux_only = pytest.mark.skipif(
+ not sys.platform.startswith("linux"),
+ reason = "covers Linux-only DRM/KFD sysfs parsing driven by a fake /sys tree",
+)
+
def _device(
index,
@@ -99,6 +111,7 @@ def _fake_drm(tmp_path, monkeypatch, cards):
return card_paths
+@linux_only
def test_linux_vram_keyed_by_pci_excludes_foreign_adapters(monkeypatch, tmp_path):
# Foreign (non-amdgpu) adapters contribute no entry, so they cannot shift ordinals.
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
@@ -117,6 +130,7 @@ def test_linux_vram_keyed_by_pci_excludes_foreign_adapters(monkeypatch, tmp_path
}
+@linux_only
def test_linux_vram_omits_bad_cards_without_shifting(monkeypatch, tmp_path):
# A zero-total card has no entry; identity keying means its absence renumbers nothing.
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
@@ -131,6 +145,7 @@ def test_linux_vram_omits_bad_cards_without_shifting(monkeypatch, tmp_path):
assert hw._rocm_linux_sysfs_vram_by_pci_gb() == {"0000:41:00.0": (2.0, 16.0)}
+@linux_only
def test_linux_vram_omits_amd_card_without_vram_files(monkeypatch, tmp_path):
# An APU with no mem_info_vram_* files has no entry; the discrete card keeps its address.
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
@@ -174,6 +189,7 @@ def _fake_kfd(tmp_path, monkeypatch, nodes):
return node_paths
+@linux_only
def test_kfd_lists_gpu_nodes_in_device_order(monkeypatch, tmp_path):
# The CPU node (simd_count 0) takes no ordinal; GPU nodes in node-id order are HIP's order.
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
@@ -189,12 +205,14 @@ def test_kfd_lists_gpu_nodes_in_device_order(monkeypatch, tmp_path):
assert hw._rocm_kfd_gpu_pci_ids() == ["0000:03:00.0", "0000:41:00.0"]
+@linux_only
def test_kfd_decodes_domain_device_and_function(monkeypatch, tmp_path):
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
_fake_kfd(tmp_path, monkeypatch, [(1, 64, (0xC1 << 8) | (0x1F << 3) | 5, 0x1234, _AMD)])
assert hw._rocm_kfd_gpu_pci_ids() == ["1234:c1:1f.5"]
+@linux_only
def test_kfd_skips_non_amd_gpu_nodes(monkeypatch, tmp_path):
# An NVIDIA KFD node is not a HIP device: it must take no ordinal, else it
# shifts every AMD GPU and ROCm device 1 resolves to AMD GPU 0.
@@ -212,6 +230,7 @@ def test_kfd_skips_non_amd_gpu_nodes(monkeypatch, tmp_path):
assert hw._rocm_kfd_gpu_pci_ids() == ["0000:03:00.0", "0000:41:00.0"]
+@linux_only
def test_kfd_fails_closed_when_a_gpu_has_no_location(monkeypatch, tmp_path):
# Dropping an unplaceable AMD GPU shifts later ordinals; fail closed for the whole map.
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
@@ -226,6 +245,7 @@ def test_kfd_fails_closed_when_a_gpu_has_no_location(monkeypatch, tmp_path):
assert hw._rocm_kfd_gpu_pci_ids() == []
+@linux_only
def test_kfd_fails_closed_when_a_node_is_unreadable(monkeypatch, tmp_path):
# An unreadable node could be a GPU; assuming otherwise would shift ordinals.
monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
@@ -241,6 +261,23 @@ def test_kfd_fails_closed_when_a_node_is_unreadable(monkeypatch, tmp_path):
assert hw._rocm_kfd_gpu_pci_ids() == []
+@linux_only
+def test_kfd_fails_closed_when_a_node_does_not_decode(monkeypatch, tmp_path):
+ # UnicodeDecodeError is a ValueError, so it slips past `except OSError` and
+ # would shift every later HIP ordinal.
+ monkeypatch.setattr(hw.platform, "system", lambda: "Linux")
+ paths = _fake_kfd(
+ tmp_path,
+ monkeypatch,
+ [
+ (1, 304, (0x03 << 8) | 0, 0, _AMD),
+ (2, 304, (0x41 << 8) | 0, 0, _AMD),
+ ],
+ )
+ (Path(paths[0]) / "properties").write_bytes(b"simd_count 304\nvendor_id \x80\xff\n")
+ assert hw._rocm_kfd_gpu_pci_ids() == []
+
+
def test_kfd_absent_yields_no_device_order(monkeypatch):
monkeypatch.setattr(hw.glob, "glob", lambda pattern: [])
assert hw._rocm_kfd_gpu_pci_ids() == []
@@ -422,6 +459,10 @@ def test_visible_utilization_rocm_fallback_overlays(monkeypatch):
):
monkeypatch.delenv(_var, raising = False)
monkeypatch.setattr(hw, "IS_ROCM", True)
+ # No AMD adapter data on this host. On Windows this branch runs ahead of the torch
+ # fallback under test, and probing it imports torch, which the CI runner does not
+ # install. Off Windows the real function is never reached, so this changes nothing.
+ monkeypatch.setattr(hw, "_rocm_windows_per_device_vram", lambda ids: [])
monkeypatch.setattr(hw, "get_device", lambda: hw.DeviceType.CUDA)
monkeypatch.setattr(hw, "_smi_query", lambda *a, **k: None) # amd-smi unavailable
monkeypatch.setattr(
@@ -450,6 +491,10 @@ def test_visible_utilization_rocm_fallback_overlays(monkeypatch):
def test_visible_utilization_relative_index_skips_overlay(monkeypatch):
# UUID/MIG mask gives relative indices; the overlay matches physical index, so it must not run.
monkeypatch.setattr(hw, "IS_ROCM", True)
+ # No AMD adapter data on this host. On Windows this branch runs ahead of the torch
+ # fallback under test, and probing it imports torch, which the CI runner does not
+ # install. Off Windows the real function is never reached, so this changes nothing.
+ monkeypatch.setattr(hw, "_rocm_windows_per_device_vram", lambda ids: [])
monkeypatch.setattr(hw, "get_device", lambda: hw.DeviceType.CUDA)
monkeypatch.setattr(hw, "_smi_query", lambda *a, **k: None)
monkeypatch.setattr(
diff --git a/studio/backend/tests/test_text_io_encoding.py b/studio/backend/tests/test_text_io_encoding.py
new file mode 100644
index 0000000000..7eae3c7fef
--- /dev/null
+++ b/studio/backend/tests/test_text_io_encoding.py
@@ -0,0 +1,809 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Text I/O must name its encoding, or Windows silently uses the ANSI codepage.
+
+``open()``, ``Path.read_text()`` and ``subprocess(text = True)`` fall back to
+``locale.getencoding()`` when no ``encoding`` is passed. On Windows that is
+cp1252 (or cp932, cp1251, ... by system locale), not UTF-8, so a chat template,
+model config or path containing ``ä ö ü → 世`` mojibakes or raises
+``UnicodeDecodeError`` mid-load. Studio's files are UTF-8, so say so.
+"""
+
+from __future__ import annotations
+
+import ast
+import importlib.util
+import json
+import os
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+
+BACKEND_ROOT = Path(__file__).resolve().parent.parent
+
+# Not runtime source. Shipped plugins under plugins/*/src are, so only builds are skipped.
+_SKIPPED_DIRS = ("node_modules", "build", "tests", "__pycache__")
+
+# Path.open()'s signature is what tells it apart from other libraries' open(),
+# e.g. fitz.open(stream=...) and av.open(..., metadata_errors=...).
+_FILE_MODE_CHARS = set("rwxabt+")
+_PATH_OPEN_ARGS = ("mode", "buffering", "encoding", "errors", "newline")
+_PATH_OPEN_KWARGS = set(_PATH_OPEN_ARGS)
+_PATH_OPEN_ENCODING_ARG = _PATH_OPEN_ARGS.index("encoding")
+
+_SUBPROCESS_CALLS = {"run", "Popen", "check_output", "check_call", "call"}
+
+# open(file, mode, buffering, encoding, ...), and os.fdopen forwards the same
+# signature with a descriptor in place of the path.
+_OPEN_ENCODING_ARG = 3
+
+
+def _studio_sources() -> list[Path]:
+ return [
+ path
+ for path in sorted(BACKEND_ROOT.rglob("*.py"))
+ if not any(part in _SKIPPED_DIRS for part in path.relative_to(BACKEND_ROOT).parts)
+ ]
+
+
+def _has_keyword(node: ast.Call, name: str) -> bool:
+ return any(keyword.arg == name for keyword in node.keywords)
+
+
+def _mode_is_binary(node: ast.Call) -> bool:
+ mode: str | None = None
+ if len(node.args) >= 2 and isinstance(node.args[1], ast.Constant):
+ value = node.args[1].value
+ mode = value if isinstance(value, str) else None
+ for keyword in node.keywords:
+ if keyword.arg == "mode" and isinstance(keyword.value, ast.Constant):
+ value = keyword.value.value
+ if isinstance(value, str):
+ mode = value
+ return bool(mode and "b" in mode)
+
+
+def _open_has_encoding(node: ast.Call) -> bool:
+ """open()/os.fdopen() also take encoding positionally: open(p, "w", 1, "utf-8")."""
+ return _has_keyword(node, "encoding") or len(node.args) > _OPEN_ENCODING_ARG
+
+
+def _path_open_mode(node: ast.Call) -> str | None:
+ if node.args and isinstance(node.args[0], ast.Constant):
+ value = node.args[0].value
+ if isinstance(value, str):
+ return value
+ for keyword in node.keywords:
+ if keyword.arg == "mode" and isinstance(keyword.value, ast.Constant):
+ value = keyword.value.value
+ if isinstance(value, str):
+ return value
+ return None
+
+
+def _is_path_open(node: ast.Call) -> bool:
+ """True only for calls matching ``Path.open``'s signature."""
+ if len(node.args) > len(_PATH_OPEN_ARGS):
+ return False
+ if any(k.arg not in _PATH_OPEN_KWARGS for k in node.keywords):
+ return False
+ mode = _path_open_mode(node)
+ if mode is not None:
+ return bool(mode) and set(mode) <= _FILE_MODE_CHARS
+ return not node.args
+
+
+def _path_open_has_encoding(node: ast.Call) -> bool:
+ """Path.open() also takes encoding positionally: open("w", 1, "utf-8")."""
+ return _has_keyword(node, "encoding") or len(node.args) > _PATH_OPEN_ENCODING_ARG
+
+
+def _call_name(node: ast.Call) -> str | None:
+ func = node.func
+ if isinstance(func, ast.Name):
+ return func.id
+ if isinstance(func, ast.Attribute):
+ return func.attr
+ return None
+
+
+def _subprocess_names(tree: ast.AST) -> set[str]:
+ """Names subprocess is reachable under here, e.g. `import subprocess as _sp`."""
+ names = set()
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ for alias in node.names:
+ if alias.name == "subprocess":
+ names.add(alias.asname or alias.name)
+ return names
+
+
+def _subprocess_aliases(tree: ast.AST, names: set[str]) -> set[str]:
+ """Plain names bound to a subprocess callable, called without the module.
+
+ ``install_wheel(run = subprocess.run)`` calls its injected ``run`` as a bare
+ name, so matching only the attribute form leaves those installer calls
+ unguarded. Imports, assignments and parameter defaults all bind one.
+ """
+
+ def _is_bound(value: ast.expr | None) -> bool:
+ return (
+ isinstance(value, ast.Attribute)
+ and value.attr in _SUBPROCESS_CALLS
+ and isinstance(value.value, ast.Name)
+ and value.value.id in names
+ )
+
+ aliases: set[str] = set()
+ for node in ast.walk(tree):
+ if isinstance(node, ast.ImportFrom) and node.module == "subprocess":
+ aliases.update(a.asname or a.name for a in node.names if a.name in _SUBPROCESS_CALLS)
+ elif isinstance(node, ast.Assign) and _is_bound(node.value):
+ aliases.update(t.id for t in node.targets if isinstance(t, ast.Name))
+ elif isinstance(node, ast.AnnAssign) and _is_bound(node.value):
+ if isinstance(node.target, ast.Name):
+ aliases.add(node.target.id)
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ args = node.args
+ positional = args.posonlyargs + args.args
+ # Defaults cover the tail of the positional parameters; kw_defaults
+ # is aligned with kwonlyargs already, holding None where absent.
+ padded = [None] * (len(positional) - len(args.defaults)) + list(args.defaults)
+ pairs = list(zip(positional, padded)) + list(zip(args.kwonlyargs, args.kw_defaults))
+ aliases.update(arg.arg for arg, default in pairs if _is_bound(default))
+ return aliases
+
+
+def _is_subprocess_call(node: ast.Call, names: set[str], aliases: set[str]) -> bool:
+ func = node.func
+ if isinstance(func, ast.Name):
+ return func.id in aliases
+ if not isinstance(func, ast.Attribute) or func.attr not in _SUBPROCESS_CALLS:
+ return False
+ value = func.value
+ return isinstance(value, ast.Name) and value.id in names
+
+
+def _text_mode_subprocess(node: ast.Call) -> bool:
+ for keyword in node.keywords:
+ if keyword.arg not in ("text", "universal_newlines"):
+ continue
+ if isinstance(keyword.value, ast.Constant) and keyword.value.value is True:
+ return True
+ return False
+
+
+def _text_mode_dict(node: ast.Dict) -> bool:
+ """A ``{"text": True, ...}`` literal with no "encoding" key."""
+ keys = [k.value for k in node.keys if isinstance(k, ast.Constant)]
+ if "encoding" in keys:
+ return False
+ for key, value in zip(node.keys, node.values):
+ if not isinstance(key, ast.Constant) or key.value not in (
+ "text",
+ "universal_newlines",
+ ):
+ continue
+ if isinstance(value, ast.Constant) and value.value is True:
+ return True
+ return False
+
+
+def _splatted_names(tree: ast.AST) -> set[str]:
+ """Names handed to a call as ``**name``."""
+ names = set()
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Call):
+ for keyword in node.keywords:
+ if keyword.arg is None and isinstance(keyword.value, ast.Name):
+ names.add(keyword.value.id)
+ return names
+
+
+def _encoding_assigned_later(tree: ast.AST, name: str) -> bool:
+ """``name["encoding"] = ...`` somewhere, so the literal need not carry it."""
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Subscript) or not isinstance(node.ctx, ast.Store):
+ continue
+ target, key = node.value, node.slice
+ if isinstance(target, ast.Name) and target.id == name:
+ if isinstance(key, ast.Constant) and key.value == "encoding":
+ return True
+ return False
+
+
+def _splatted_kwargs_offenders(tree: ast.AST) -> list[ast.Dict]:
+ """Text-mode kwargs built in a dict and splatted into a call.
+
+ Kwargs are collected in a dict and splatted (``run(cmd, **run_kwargs)``)
+ where a branch has to add a timeout or an env, and the call is often through
+ a helper, so neither the callee nor the keywords are visible at the call
+ site. Only dicts that reach a call this way are judged: an unrelated payload
+ that happens to carry ``"text": True`` is not subprocess configuration.
+ """
+ found = []
+ # ``run(cmd, **{...})``: the literal is at the call already.
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ for keyword in node.keywords:
+ if keyword.arg is None and isinstance(keyword.value, ast.Dict):
+ if _text_mode_dict(keyword.value):
+ found.append(keyword.value)
+ splatted = _splatted_names(tree)
+ if not splatted:
+ return found
+ for node in ast.walk(tree):
+ targets = []
+ if isinstance(node, ast.Assign):
+ targets = [t for t in node.targets if isinstance(t, ast.Name)]
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
+ targets = [node.target]
+ if not targets or not isinstance(node.value, ast.Dict):
+ continue
+ if not _text_mode_dict(node.value):
+ continue
+ for target in targets:
+ if target.id in splatted and not _encoding_assigned_later(tree, target.id):
+ found.append(node.value)
+ break
+ return found
+
+
+def _offenders(path: Path) -> list[str]:
+ source = path.read_text(encoding = "utf-8")
+ tree = ast.parse(source, filename = str(path))
+ subprocess_names = _subprocess_names(tree)
+ subprocess_aliases = _subprocess_aliases(tree, subprocess_names)
+ found: list[str] = []
+ for node in _splatted_kwargs_offenders(tree):
+ found.append(
+ f"{path.name}:{node.lineno}: subprocess kwargs with text = True and no encoding"
+ )
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ name = _call_name(node)
+
+ if _is_subprocess_call(node, subprocess_names, subprocess_aliases):
+ if _text_mode_subprocess(node) and not _has_keyword(node, "encoding"):
+ found.append(f"{path.name}:{node.lineno}: subprocess(text = True) without encoding")
+ continue
+
+ if name == "open" and isinstance(node.func, ast.Name):
+ if _mode_is_binary(node) or _open_has_encoding(node):
+ continue
+ found.append(f"{path.name}:{node.lineno}: open() without encoding")
+ continue
+
+ # os.fdopen(fd, "w") is open() on a descriptor, so text mode takes the
+ # same locale default. Its mode defaults to "r", i.e. text, like open's.
+ if name == "fdopen":
+ if _mode_is_binary(node) or _open_has_encoding(node):
+ continue
+ found.append(f"{path.name}:{node.lineno}: os.fdopen() without encoding")
+ continue
+
+ if name == "open" and isinstance(node.func, ast.Attribute):
+ if not _is_path_open(node) or _path_open_has_encoding(node):
+ continue
+ if _path_open_mode(node) and "b" in _path_open_mode(node):
+ continue
+ found.append(f"{path.name}:{node.lineno}: Path.open() without encoding")
+ continue
+
+ if name in ("read_text", "write_text") and isinstance(node.func, ast.Attribute):
+ if _has_keyword(node, "encoding"):
+ continue
+ # importlib.metadata Distribution.read_text() takes no encoding kwarg.
+ if isinstance(node.func.value, ast.Name) and node.func.value.id == "dist":
+ continue
+ found.append(f"{path.name}:{node.lineno}: {name}() without encoding")
+ return found
+
+
+@pytest.mark.parametrize("path", _studio_sources(), ids = lambda p: str(p.name))
+def test_text_io_names_its_encoding(path: Path) -> None:
+ offenders = _offenders(path)
+ assert not offenders, (
+ "Text I/O without an explicit encoding falls back to the Windows ANSI "
+ 'codepage and corrupts non-ASCII (ä ö ü → 世). Pass encoding = "utf-8":\n '
+ + "\n ".join(offenders)
+ )
+
+
+_STATE_STORE = (
+ BACKEND_ROOT
+ / "plugins/data-designer-github-repo-seed/src"
+ / "data_designer_github_repo_seed/scraper_impl/state_store.py"
+)
+
+
+def _load_state_store(codepage: str):
+ """Load state_store with the writing machine's codepage pinned."""
+ spec = importlib.util.spec_from_file_location(f"state_store_{codepage}", _STATE_STORE)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ module.locale = SimpleNamespace(
+ getencoding = lambda: codepage,
+ getpreferredencoding = lambda _ = True: codepage,
+ )
+ return module
+
+
+@pytest.mark.parametrize(
+ ("codepage", "name"), [("cp1252", "Jürgen"), ("cp1251", "Юрий"), ("cp932", "田中")]
+)
+def test_resuming_a_legacy_jsonl_keeps_one_encoding(
+ tmp_path: Path, codepage: str, name: str
+) -> None:
+ """A scrape written before UTF-8 was explicit must resume, not duplicate."""
+ path = tmp_path / "out.jsonl"
+ records = [{"id": 1, "author": name}, {"id": 2, "author": name}]
+ body = "".join(json.dumps(r, ensure_ascii = False) + "\n" for r in records)
+ path.write_bytes(body.encode(codepage))
+ before = path.read_bytes()
+
+ writer = _load_state_store(codepage).JsonlWriter(path)
+ try:
+ # Seen keys survive the resume, so a repeat is refused, not appended.
+ assert writer.has("id:1") and writer.has("id:2")
+ assert writer.write(records[0]) is False
+ assert writer.write({"id": 3, "author": name}) is True
+ finally:
+ writer.close()
+
+ # Never converted, so it still reads in its own codepage; the append is ASCII.
+ blob = path.read_bytes()
+ assert blob.startswith(before)
+ assert blob[len(before) :].isascii()
+ lines = [json.loads(x) for x in blob.decode(codepage).splitlines() if x.strip()]
+ assert len(lines) == 3
+ assert [line["author"] for line in lines] == [name] * 3
+
+
+def test_a_coincidentally_utf8_legacy_line_is_left_alone(tmp_path: Path) -> None:
+ """cp1251 `Р°` is D0 B0, which is also UTF-8 `а`, and nothing can tell them apart."""
+ path = tmp_path / "out.jsonl"
+ ambiguous = "Р°"
+ assert ambiguous.encode("cp1251").decode("utf-8") == "а" # the trap
+ authors = ["Привет", "Здравствуйте", "Москва", ambiguous]
+ path.write_bytes(
+ b"".join(
+ json.dumps({"id": i, "author": a}, ensure_ascii = False).encode("cp1251") + b"\n"
+ for i, a in enumerate(authors)
+ )
+ )
+ before = path.read_bytes()
+
+ _load_state_store("cp1251").JsonlWriter(path).close()
+
+ # Untouched, so the ambiguity never had to be resolved.
+ assert path.read_bytes() == before
+ rows = [json.loads(x) for x in path.read_text(encoding = "cp1251").splitlines() if x.strip()]
+ assert [row["author"] for row in rows] == authors
+
+
+@pytest.mark.parametrize(
+ ("codepage", "word"), [("cp1251", "Привет"), ("cp932", "こんにちは"), ("cp1252", "Jürgen")]
+)
+def test_a_moved_shard_is_not_rewritten_by_guesswork(
+ tmp_path: Path, codepage: str, word: str
+) -> None:
+ """Off the writing machine there is no codepage to attribute the file to."""
+ path = tmp_path / "out.jsonl"
+ # Two records: a lone non-UTF-8 line would count as damage, not legacy.
+ path.write_bytes(
+ b"".join(
+ json.dumps({"id": i, "author": word}, ensure_ascii = False).encode(codepage) + b"\n"
+ for i in (1, 4)
+ )
+ )
+ before = path.read_bytes()
+
+ # A UTF-8 host: latin-1 would read cp1251 `Привет` back as `Ïðèâåò`.
+ writer = _load_state_store("utf-8").JsonlWriter(path)
+ try:
+ assert writer.has("id:1") # ASCII keys still recover
+ assert writer.write({"id": 2, "author": "Grüße"}) is True
+ finally:
+ writer.close()
+
+ blob = path.read_bytes()
+ assert blob.startswith(before) # never rewritten
+ assert blob[len(before) :].isascii() # appended as \uXXXX, so no second encoding
+ rows = [json.loads(x) for x in blob.decode(codepage).splitlines() if x.strip()]
+ assert [row["author"] for row in rows] == [word, word, "Grüße"]
+
+
+def test_an_all_ambiguous_shard_still_gets_ascii_appends(tmp_path: Path) -> None:
+ """Every line valid under both readings still means the append must not pick one."""
+ path = tmp_path / "out.jsonl"
+ ambiguous = "Р°" # cp1251 D0 B0, also valid UTF-8 for "а"
+ path.write_bytes(
+ b"".join(
+ json.dumps({"id": i, "a": ambiguous}, ensure_ascii = False).encode("cp1251") + b"\n"
+ for i in range(3)
+ )
+ )
+ before = path.read_bytes()
+
+ writer = _load_state_store("cp1251").JsonlWriter(path)
+ try:
+ assert writer.write({"id": 9, "a": "世界"}) is True
+ finally:
+ writer.close()
+
+ blob = path.read_bytes()
+ assert blob.startswith(before)
+ # ASCII, so the appended record survives whichever reading is chosen.
+ assert blob[len(before) :].isascii()
+ for codec in ("cp1251", "utf-8"):
+ rows = [json.loads(x) for x in blob.decode(codec).splitlines() if x.strip()]
+ assert rows[-1]["a"] == "世界"
+
+
+def test_a_damaged_line_in_an_ascii_shard_does_not_block_its_retry(tmp_path: Path) -> None:
+ """With no non-ASCII records to outvote it, one damaged line is still damage."""
+ path = tmp_path / "out.jsonl"
+ path.write_bytes(
+ b'{"id": 1, "author": "alice"}\n'
+ + b'{"id": 99, "author": "bad \x96 byte"}\n'
+ + b'{"id": 2, "author": "bob"}\n'
+ )
+
+ writer = _load_state_store("cp1252").JsonlWriter(path)
+ try:
+ assert writer.has("id:1") and writer.has("id:2")
+ assert not writer.has("id:99")
+ assert writer.write({"id": 99, "author": "good byte"}) is True
+ finally:
+ writer.close()
+
+
+def test_a_damaged_line_does_not_block_its_own_retry(tmp_path: Path) -> None:
+ """Its key comes from the codepage reading, which a UTF-8 shard did not pick."""
+ path = tmp_path / "out.jsonl"
+ path.write_bytes(
+ json.dumps({"id": 1, "author": "Jürgen"}, ensure_ascii = False).encode()
+ + b"\n"
+ + b'{"id": 99, "author": "bad \x96 byte"}\n'
+ )
+
+ writer = _load_state_store("cp1252").JsonlWriter(path)
+ try:
+ assert writer.has("id:1")
+ assert not writer.has("id:99")
+ assert writer.write({"id": 99, "author": "good byte"}) is True
+ finally:
+ writer.close()
+
+
+def test_one_damaged_byte_does_not_relabel_a_utf8_shard(tmp_path: Path) -> None:
+ """A complete JSON line with a stray 0x96 parses as cp1252, but is only one vote."""
+ path = tmp_path / "out.jsonl"
+ healthy = ["Jürgen", "Grüße", "Björn"]
+ path.write_bytes(
+ json.dumps({"id": 0, "author": healthy[0]}, ensure_ascii = False).encode()
+ + b"\n"
+ + b'{"id": 99, "author": "bad \x96 byte"}\n'
+ + b"".join(
+ json.dumps({"id": i, "author": a}, ensure_ascii = False).encode() + b"\n"
+ for i, a in enumerate(healthy[1:], start = 1)
+ )
+ )
+ before = path.read_bytes()
+
+ _load_state_store("cp1252").JsonlWriter(path).close()
+
+ # Untouched, so the healthy records were never re-read as cp1252.
+ assert path.read_bytes() == before
+ rows = []
+ for line in path.read_bytes().splitlines():
+ try:
+ rows.append(json.loads(line.decode()))
+ except (UnicodeDecodeError, ValueError):
+ continue
+ assert [row["author"] for row in rows] == healthy
+
+
+def test_a_torn_line_does_not_relabel_a_utf8_shard(tmp_path: Path) -> None:
+ """One interrupted append must not get the whole shard read as cp1252."""
+ path = tmp_path / "out.jsonl"
+ good = [{"id": 1, "author": "Jürgen"}, {"id": 3, "author": "Grüße"}]
+ torn = '{"id": 2, "author": "Jürgen"}'.encode()[:-6] # cut mid-character
+ path.write_bytes(
+ json.dumps(good[0], ensure_ascii = False).encode()
+ + b"\n"
+ + torn
+ + b"\n"
+ + json.dumps(good[1], ensure_ascii = False).encode()
+ + b"\n"
+ )
+ before = path.read_bytes()
+
+ writer = _load_state_store("cp1252").JsonlWriter(path)
+ try:
+ assert writer.has("id:1") and writer.has("id:3")
+ assert not writer.has("id:2") # torn line yields no key
+ finally:
+ writer.close()
+
+ # Untouched: no rewrite, so no record was re-encoded into mojibake.
+ after = path.read_bytes()
+ assert after.startswith(before)
+ assert "Jürgen".encode() in after
+ assert "Jürgen".encode("utf-8").decode("cp1252").encode() not in after
+
+
+def test_an_undecodable_transport_marker_reads_as_unknown(tmp_path: Path) -> None:
+ """Pinning the decode turns an undecodable marker into UnicodeDecodeError,
+ which is a ValueError and so is not an OSError. Before the pin those bytes
+ simply read as an unknown value and the caller safely purged and restarted
+ the partial download; letting the error escape aborts the transfer instead.
+ """
+ import sys
+
+ backend = str(Path(__file__).resolve().parent.parent)
+ if backend not in sys.path:
+ sys.path.insert(0, backend)
+ from hub.utils import download_registry as registry
+
+ marker = tmp_path / ".transport"
+ marker.write_bytes(b"\x80\xffnative\n")
+ assert registry._read_marker_value(marker) is None
+ # A readable but unknown value takes the same path (the behaviour restored).
+ marker.write_text("something-else\n", encoding = "utf-8")
+ assert registry._read_marker_value(marker) is None
+
+
+def test_a_torn_cache_ref_reads_as_not_cached(tmp_path: Path, monkeypatch) -> None:
+ """hf_cache_snapshot_dir answers "is this model already on disk", and the
+ offline embedding checks turn a raise into a 500. A refs/main holding a byte
+ the codepage used to decode into a nonsense commit simply missed the snapshot
+ dir before the pin; it has to keep missing it."""
+ import sys
+
+ backend = str(Path(__file__).resolve().parent.parent)
+ if backend not in sys.path:
+ sys.path.insert(0, backend)
+ from utils import utils as backend_utils
+
+ good_root = tmp_path / "good"
+ torn_root = tmp_path / "torn"
+ for root, ref_bytes in ((torn_root, b"\x80\xff\n"), (good_root, b"abc123\n")):
+ repo = root / "models--Org--Model"
+ (repo / "refs").mkdir(parents = True)
+ (repo / "refs" / "main").write_bytes(ref_bytes)
+ (good_root / "models--Org--Model" / "snapshots" / "abc123").mkdir(parents = True)
+
+ monkeypatch.setattr(backend_utils, "_hf_cache_roots", lambda: [torn_root])
+ assert backend_utils.hf_cache_snapshot_dir("Org/Model") is None
+ # The torn root is skipped, not fatal: a healthy second root still answers.
+ monkeypatch.setattr(backend_utils, "_hf_cache_roots", lambda: [torn_root, good_root])
+ found = backend_utils.hf_cache_snapshot_dir("Org/Model")
+ assert found is not None and found.name == "abc123"
+
+
+def test_a_corrupt_pid_file_does_not_abort_shutdown(tmp_path: Path, monkeypatch) -> None:
+ """_remove_pid_file runs first in _graceful_shutdown, so a raise there leaves
+ the inference, export, training and tunnel children alive."""
+ import sys
+
+ backend = str(Path(__file__).resolve().parent.parent)
+ if backend not in sys.path:
+ sys.path.insert(0, backend)
+ import run as studio_run
+
+ pid_file = tmp_path / "studio.pid"
+ pid_file.write_bytes(b"\x80\xff")
+ monkeypatch.setattr(studio_run, "_PID_FILE", pid_file)
+ studio_run._remove_pid_file()
+ # Not this process's PID, so the file stays; the point is that it returned.
+ assert pid_file.exists()
+
+ pid_file.write_text(str(os.getpid()), encoding = "utf-8")
+ studio_run._remove_pid_file()
+ assert not pid_file.exists()
+
+
+def test_the_kwargs_guard_only_judges_dicts_that_reach_a_call(tmp_path: Path) -> None:
+ """Only a dict splatted into a call is subprocess configuration. An unrelated
+ payload that happens to carry "text": True is not, and neither is one whose
+ encoding is filled in on a later line."""
+ cases = {
+ "offender.py": 'kw = {"text": True}\nrun(cmd, **kw)\n',
+ "annotated.py": 'kw: dict = {"universal_newlines": True}\nrun(cmd, **kw)\n',
+ "payload.py": 'payload = {"text": True}\nrequests.post(url, json = payload)\n',
+ "inline.py": 'run(cmd, **{"text": True})\n',
+ "later.py": 'kw = {"text": True}\nkw["encoding"] = "utf-8"\nrun(cmd, **kw)\n',
+ "carried.py": 'kw = {"text": True, "encoding": "utf-8"}\nrun(cmd, **kw)\n',
+ }
+ flagged = set()
+ for name, source in cases.items():
+ path = tmp_path / name
+ path.write_text(source, encoding = "utf-8")
+ if any("subprocess kwargs" in line for line in _offenders(path)):
+ flagged.add(name)
+ assert flagged == {"offender.py", "annotated.py", "inline.py"}, flagged
+
+
+def test_the_guard_follows_subprocess_through_an_alias(tmp_path: Path) -> None:
+ """install_wheel() takes ``run = subprocess.run`` and calls it as a bare
+ name, so an attribute-only match let both of its installer calls drop their
+ encoding unnoticed. A name bound to something else is still not subprocess."""
+ cases = {
+ "param_default.py": (
+ "import subprocess\n"
+ "def install(*, run = subprocess.run):\n"
+ " run(cmd, text = True)\n"
+ ),
+ "assigned.py": "import subprocess\n_run = subprocess.run\n_run(cmd, text = True)\n",
+ "imported.py": "from subprocess import check_output\ncheck_output(cmd, text = True)\n",
+ "renamed.py": "from subprocess import run as _r\n_r(cmd, universal_newlines = True)\n",
+ "encoded.py": (
+ "import subprocess\n"
+ "def install(*, run = subprocess.run):\n"
+ ' run(cmd, text = True, encoding = "utf-8")\n'
+ ),
+ "unrelated.py": "def run(cmd, text = False):\n pass\nrun(cmd, text = True)\n",
+ }
+ flagged = set()
+ for name, source in cases.items():
+ path = tmp_path / name
+ path.write_text(source, encoding = "utf-8")
+ if any("subprocess(text = True)" in line for line in _offenders(path)):
+ flagged.add(name)
+ assert flagged == {"param_default.py", "assigned.py", "imported.py", "renamed.py"}, flagged
+
+
+def test_the_guard_sees_os_fdopen(tmp_path: Path) -> None:
+ """os.fdopen(fd, mode) is open() on a descriptor and takes the same locale
+ default in text mode, so leaving it out let the swap lock file keep the
+ codepage on the write side while its reader was pinned to UTF-8."""
+ cases = {
+ "text.py": 'import os\nos.fdopen(fd, "w")\n',
+ "default_mode.py": "import os\nos.fdopen(fd)\n", # defaults to "r", still text
+ "binary.py": 'import os\nos.fdopen(fd, "wb")\n',
+ "keyword.py": 'import os\nos.fdopen(fd, "w", encoding = "utf-8")\n',
+ "positional.py": 'import os\nos.fdopen(fd, "w", 1, "utf-8")\n',
+ }
+ flagged = set()
+ for name, source in cases.items():
+ path = tmp_path / name
+ path.write_text(source, encoding = "utf-8")
+ if any("fdopen" in line for line in _offenders(path)):
+ flagged.add(name)
+ assert flagged == {"text.py", "default_mode.py"}, flagged
+
+
+def test_an_undecodable_bootstrap_password_does_not_stop_startup(
+ tmp_path: Path, monkeypatch
+) -> None:
+ """ensure_default_admin calls _load_bootstrap_password for every existing
+ admin and the lifespan calls that with no handler, so a raise here takes the
+ whole backend down instead of ignoring an unusable file."""
+ import sys
+
+ backend = str(Path(__file__).resolve().parent.parent)
+ if backend not in sys.path:
+ sys.path.insert(0, backend)
+ from auth import storage
+
+ pw_file = tmp_path / ".bootstrap_password"
+ pw_file.write_bytes(b"\x80\xffnot-utf8\n")
+ monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", pw_file)
+ assert storage._load_bootstrap_password() is None
+
+ # A readable one still loads, so this is a narrowing of failure, not of function.
+ pw_file.write_text("correct horse battery staple\n", encoding = "utf-8")
+ assert storage._load_bootstrap_password() == "correct horse battery staple"
+
+
+def test_a_damaged_checkpoint_resets_instead_of_resuming_on_a_broken_cursor(tmp_path: Path) -> None:
+ """A checkpoint holds only base64 cursors and booleans, so a codepage reading
+ can only ever add non-ASCII, never recover any. Resuming on a mojibaked cursor
+ sends GitHub one it answers with INVALID_CURSOR_ARGUMENTS, and the empty page
+ that comes back marks the stream done and skips the rest of it for good.
+ Dropping the checkpoint only replays pages the writers already dedup."""
+ module = _load_state_store("cp1252")
+ cursor = "Y3Vyc29yOnYyOpK0MjAxMi0wMi0xNlQwNjo1Mzo0MVrOADGL_A=="
+ healthy = json.dumps({"issues_cursor": cursor, "issues_done": False}, indent = 2)
+ path = tmp_path / "octocat__Hello-World.json"
+
+ path.write_text(healthy, encoding = "utf-8")
+ assert module.StateStore(path).get("issues_cursor") == cursor
+
+ # Written by a pre-UTF-8 release in the operator's codepage. Nothing is lost
+ # by reading UTF-8 only, because an all-ASCII document is the same bytes.
+ path.write_bytes(healthy.encode("cp1252"))
+ assert module.StateStore(path).get("issues_cursor") == cursor
+
+ # One damaged byte inside the cursor: still a whole JSON document under a
+ # single-byte codepage, so only refusing that reading resets the checkpoint.
+ raw = healthy.encode()
+ at = raw.index(b"MjAxMi0wMi0xNlQ") + 3
+ path.write_bytes(raw[:at] + b"\x96" + raw[at + 1 :])
+ assert json.loads(path.read_bytes().decode("latin-1"))["issues_cursor"] != cursor
+ store = module.StateStore(path)
+ assert store.all() == {}
+ assert store.get("issues_cursor") is None
+
+
+def test_a_utf8_record_is_not_parsed_a_second_time(tmp_path: Path) -> None:
+ """These shards reach gigabytes and every resume reads all of one, so a
+ record that already read as UTF-8 must not be decoded and parsed again under
+ the codepage. The legacy reading exists only to recover keys UTF-8 could not."""
+ module = _load_state_store("cp1252")
+ calls: list[str] = []
+ real_parse = module._parse
+
+ def counting_parse(raw, encoding):
+ calls.append(encoding)
+ return real_parse(raw, encoding)
+
+ module._parse = counting_parse
+ try:
+ healthy = json.dumps({"id": 1, "author": "Jürgen"}).encode("utf-8")
+ reading = module._read_line(healthy, "cp1252")
+ assert reading.as_utf8 == {"id": 1, "author": "Jürgen"}
+ assert calls == ["utf-8"], calls
+
+ # A line UTF-8 cannot read still falls through to the codepage, the whole point.
+ calls.clear()
+ legacy = json.dumps({"id": 2, "author": "Jürgen"}, ensure_ascii = False).encode("cp1252")
+ reading = module._read_line(legacy, "cp1252")
+ assert reading.as_utf8 is None
+ assert reading.as_legacy == {"id": 2, "author": "Jürgen"}
+ assert calls == ["utf-8", "cp1252"], calls
+ finally:
+ module._parse = real_parse
+
+
+def _too_deeply_nested_json() -> str:
+ """A JSON document nested past what this interpreter will descend into.
+
+ Probed rather than hardcoded: the depth json.loads gives up at is bounded by
+ sys.getrecursionlimit() up to 3.11 and by the C recursion limit from 3.12,
+ which sys.setrecursionlimit no longer moves and which varies by micro
+ version. That is ~995 on 3.9 and ~9999 on 3.13.
+ """
+ depth = 1
+ while depth <= 1 << 17:
+ document = "[" * depth + "]" * depth
+ try:
+ json.loads(document)
+ except RecursionError:
+ return document
+ depth *= 2
+ pytest.skip("this interpreter parses arbitrarily nested JSON")
+
+
+def test_an_unparseably_nested_document_is_discarded_not_raised(tmp_path: Path) -> None:
+ """json.loads answers nesting it cannot descend with RecursionError, which is
+ a RuntimeError and so is neither a ValueError nor a UnicodeDecodeError.
+ _parse is called outside any other handler in both StateStore.__init__ and
+ JsonlWriter._scan_existing, so letting it escape aborts the scraper at
+ startup on a file the catch-all it replaced simply discarded."""
+ module = _load_state_store("cp1252")
+ nested = _too_deeply_nested_json()
+
+ checkpoint = tmp_path / "octocat__Hello-World.json"
+ checkpoint.write_text(nested, encoding = "utf-8")
+ assert module.StateStore(checkpoint).all() == {} # reset, not raised
+
+ shard = tmp_path / "out.jsonl"
+ shard.write_text(
+ nested + "\n" + json.dumps({"id": 1}) + "\n" + json.dumps({"id": 2}) + "\n",
+ encoding = "utf-8",
+ )
+ writer = module.JsonlWriter(shard)
+ try:
+ # Skipped like any other unreadable line, so its neighbours still yield the dedup
+ # keys that keep the resume from re-fetching them.
+ assert writer.has("id:1") and writer.has("id:2")
+ finally:
+ writer.close()
diff --git a/studio/backend/tests/test_training_worker_flash_attn.py b/studio/backend/tests/test_training_worker_flash_attn.py
index 86511987b1..d136821ea2 100644
--- a/studio/backend/tests/test_training_worker_flash_attn.py
+++ b/studio/backend/tests/test_training_worker_flash_attn.py
@@ -9,8 +9,28 @@ import sys
from typing import Any
from unittest import mock
+import pytest
+
from core.training import worker
+# The runtime install is Linux-only, so elsewhere these return before any status.
+linux_only = pytest.mark.skipif(
+ not sys.platform.startswith("linux"),
+ reason = "the runtime flash-attn install is gated to Linux",
+)
+
+# causal-conv1d and flash-linear-attention are NOT Linux-gated: both installers bail out
+# on `sys.platform == "win32"` alone (no prebuilt wheel for Windows) and run everywhere
+# else, macOS included. linux_only here would skip cases that legitimately pass off Linux.
+not_on_windows = pytest.mark.skipif(
+ sys.platform == "win32",
+ reason = (
+ "mirrors the sys.platform == 'win32' bail-out in "
+ "_ensure_flash_linear_attention_unconditional and "
+ "_ensure_causal_conv1d_fast_path"
+ ),
+)
+
def _missing_flash_attn_import():
real_import = builtins.__import__
@@ -55,6 +75,7 @@ def test_should_try_runtime_flash_attn_install_threshold_and_skip(monkeypatch):
assert worker._should_try_runtime_flash_attn_install(32768) is False
+@linux_only
def test_runtime_flash_attn_prefers_prebuilt_wheel(monkeypatch):
statuses: list[str] = []
@@ -82,6 +103,7 @@ def test_runtime_flash_attn_prefers_prebuilt_wheel(monkeypatch):
assert statuses == ["Installing flash-attn for faster training..."]
+@linux_only
def test_runtime_flash_attn_falls_back_to_pypi(monkeypatch):
calls: list[list[str]] = []
statuses: list[str] = []
@@ -113,12 +135,7 @@ def test_runtime_flash_attn_falls_back_to_pypi(monkeypatch):
)
monkeypatch.setattr(worker, "install_wheel", mock.Mock())
- def fake_run(
- cmd,
- stdout = None,
- stderr = None,
- text = None,
- ):
+ def fake_run(cmd, **kwargs):
calls.append(list(cmd))
return subprocess.CompletedProcess(cmd, 0, "")
@@ -139,6 +156,7 @@ def test_runtime_flash_attn_skip_env_avoids_all_install_work(monkeypatch):
worker._sp.run.assert_not_called()
+@not_on_windows
def test_causal_conv1d_fast_path_preserves_wheel_first_install_args(monkeypatch):
install_mock = mock.Mock(return_value = True)
monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)
@@ -160,6 +178,7 @@ def test_causal_conv1d_fast_path_preserves_wheel_first_install_args(monkeypatch)
)
+@not_on_windows
def test_causal_conv1d_fast_path_includes_qwen3_6_variants(monkeypatch):
install_mock = mock.Mock(return_value = True)
monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)
@@ -225,6 +244,7 @@ def _pin_fla_model_types(monkeypatch):
)
+@not_on_windows
def test_flash_linear_attention_installs_pinned_pair_for_qwen3_5(monkeypatch):
_pin_fla_model_types(monkeypatch)
monkeypatch.setattr(worker.shutil, "which", lambda name: "/usr/bin/uv")
@@ -277,6 +297,7 @@ def test_flash_linear_attention_skips_for_ssm_only_models(monkeypatch):
run_mock.assert_not_called()
+@not_on_windows
def test_flash_linear_attention_matches_full_qwen3_family(monkeypatch):
monkeypatch.setattr(worker.shutil, "which", lambda name: "/usr/bin/uv")
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
@@ -331,6 +352,7 @@ def test_flash_linear_attention_skipped_via_env(monkeypatch):
run_mock.assert_not_called()
+@not_on_windows
def test_flash_linear_attention_skipped_below_torch_2_7(monkeypatch):
_pin_fla_model_types(monkeypatch)
monkeypatch.delenv(worker._FLA_SKIP_ENV, raising = False)
@@ -349,6 +371,7 @@ def test_flash_linear_attention_skipped_below_torch_2_7(monkeypatch):
assert any("torch>=" in s for s in statuses)
+@not_on_windows
def test_flash_linear_attention_install_includes_einops(monkeypatch):
_pin_fla_model_types(monkeypatch)
monkeypatch.delenv(worker._FLA_SKIP_ENV, raising = False)
@@ -375,6 +398,7 @@ def test_flash_linear_attention_install_includes_einops(monkeypatch):
assert f"fla-core=={worker._FLA_CORE_PACKAGE_VERSION}" in args
+@not_on_windows
def test_flash_linear_attention_logs_post_install_import_failure(monkeypatch):
"""pip exits 0 but `import fla.modules` still fails (missing transitive)."""
_pin_fla_model_types(monkeypatch)
@@ -421,6 +445,7 @@ def test_tilelang_backend_skipped_on_unsupported_linux_arch(monkeypatch):
run_mock.assert_not_called()
+@linux_only
def test_tilelang_backend_pins_only_binary(monkeypatch):
_pin_fla_model_types(monkeypatch)
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
@@ -462,6 +487,7 @@ def _force_missing_tilelang_imports(monkeypatch):
monkeypatch.setattr(builtins, "__import__", fake_import)
+@linux_only
def test_tilelang_backend_installs_pinned_pair_for_qwen3_5(monkeypatch):
_pin_fla_model_types(monkeypatch)
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
@@ -486,6 +512,7 @@ def test_tilelang_backend_installs_pinned_pair_for_qwen3_5(monkeypatch):
assert any("Installing TileLang" in s for s in statuses)
+@linux_only
def test_tilelang_backend_reinstalls_when_tvm_ffi_is_broken(monkeypatch):
"""Repair path issues TWO pip calls:
@@ -555,6 +582,7 @@ def test_tilelang_backend_skipped_on_windows(monkeypatch):
run_mock.assert_not_called()
+@linux_only
def test_tilelang_backend_swallows_install_timeout(monkeypatch):
_pin_fla_model_types(monkeypatch)
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
@@ -609,6 +637,7 @@ def test_tilelang_backend_skipped_via_env(monkeypatch):
run_mock.assert_not_called()
+@linux_only
def test_tilelang_backend_swallows_install_failure(monkeypatch):
_pin_fla_model_types(monkeypatch)
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
@@ -673,6 +702,7 @@ def _patch_iu_gates(monkeypatch, fla_gate, conv_gate):
monkeypatch.setattr(_iu, "is_causal_conv1d_available", conv_gate)
+@not_on_windows
def test_hook_installs_when_gate_returns_false(monkeypatch):
_pin_fla_model_types(monkeypatch)
fla_gate = _make_fake_gate(initial_return = False)
@@ -976,6 +1006,7 @@ def test_hook_does_install_tilelang_for_qwen35(monkeypatch):
tile_install.assert_called_once()
+@linux_only
def test_tilelang_repair_does_not_touch_torch_cuda_stack(monkeypatch):
"""Finding #2: the broken-tvm-ffi repair must use --no-deps on the
forced step so --force-reinstall doesn't cascade through
@@ -1119,6 +1150,7 @@ def test_hook_runs_tilelang_repair_when_fla_already_true(monkeypatch):
tile_install.assert_called_once()
+@not_on_windows
def test_fla_installer_force_reinstalls_when_older_version_present(monkeypatch):
"""Finding #8: an older `flash-linear-attention` that is importable
but below the pin must force a reinstall (not no-op).
@@ -1583,15 +1615,10 @@ def test_install_respects_user_gcc_install_dir(monkeypatch):
)
_make_hip_install_env(monkeypatch, gcc_dir = "/usr/lib/gcc/x86_64-linux-gnu/13")
- captured: dict[str, str] | None = {"_called": "no"}
+ captured: dict[str, str] = {}
def fake_run(cmd, **kwargs):
- env = kwargs.get("env")
- if env is not None:
- captured.clear()
- captured.update(env)
- else:
- captured["_called"] = "yes_no_env"
+ captured.update(kwargs.get("env") or {})
return subprocess.CompletedProcess(cmd, 0, "")
monkeypatch.setattr(worker._sp, "run", fake_run)
@@ -1607,14 +1634,11 @@ def test_install_respects_user_gcc_install_dir(monkeypatch):
release_base_url = "https://example.com",
)
- # subprocess.run invoked without env override (user already set
- # HIPCC_COMPILE_FLAGS_APPEND with --gcc-install-dir, so we left the
- # env alone — the existing value is inherited).
- assert captured == {"_called": "yes_no_env"}
+ assert captured["HIPCC_COMPILE_FLAGS_APPEND"] == "--gcc-install-dir=/opt/custom/gcc-13"
def test_install_does_not_inject_env_on_cuda(monkeypatch):
- """CUDA path (no hip_version in env) → no env override at all."""
+ """CUDA path (no hip_version in env) → no HIP flag injected."""
monkeypatch.delenv("HIPCC_COMPILE_FLAGS_APPEND", raising = False)
monkeypatch.setattr(builtins, "__import__", _missing_module_import("causal_conv1d"))
monkeypatch.setattr(
@@ -1641,7 +1665,7 @@ def test_install_does_not_inject_env_on_cuda(monkeypatch):
captured: dict[str, Any] = {}
def fake_run(cmd, **kwargs):
- captured["env_in_kwargs"] = "env" in kwargs
+ captured.update(kwargs.get("env") or {})
return subprocess.CompletedProcess(cmd, 0, "")
monkeypatch.setattr(worker._sp, "run", fake_run)
@@ -1657,5 +1681,5 @@ def test_install_does_not_inject_env_on_cuda(monkeypatch):
release_base_url = "https://example.com",
)
- # CUDA branch never sets the env, never invokes the gcc helper.
- assert captured.get("env_in_kwargs") is False
+ # env is always passed (to force UTF-8), but never the HIP flag.
+ assert "HIPCC_COMPILE_FLAGS_APPEND" not in captured
diff --git a/studio/backend/utils/changelog.py b/studio/backend/utils/changelog.py
new file mode 100644
index 0000000000..84cd54df05
--- /dev/null
+++ b/studio/backend/utils/changelog.py
@@ -0,0 +1,1056 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Release notes for the update popup, sourced from CHANGELOG.md.
+
+Notes are keyed to one exact version: the popup asks for the version it is
+offering and gets that section or nothing, so an older release's notes can
+never appear next to a newer update.
+
+The remote copy on the default branch wins over the bundled one, since the
+offered version is newer than the installed checkout. Both reads are lazy,
+cached and skipped when update checks are off.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import threading
+import time
+import urllib.request
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from packaging.version import InvalidVersion, Version
+
+from .update_status import DISABLE_ENV_VAR, RELEASE_NOTES_URL
+
+CHANGELOG_FILENAME = "CHANGELOG.md"
+CHANGELOG_RAW_URL = "https://raw.githubusercontent.com/unslothai/unsloth/main/CHANGELOG.md"
+CHANGELOG_URL_ENV_VAR = "UNSLOTH_CHANGELOG_URL"
+CHANGELOG_PATH_ENV_VAR = "UNSLOTH_CHANGELOG_PATH"
+CHANGELOG_TIMEOUT_SECONDS = 3
+CHANGELOG_MAX_BYTES = 2 * 1024 * 1024
+_CHANGELOG_CHUNK_BYTES = 64 * 1024
+_CHANGELOG_MIN_READ_SECONDS = 0.05
+CHANGELOG_SUCCESS_TTL_SECONDS = 30 * 60
+CHANGELOG_FAILURE_TTL_SECONDS = 5 * 60
+RELEASE_NOTES_MAX_CHARS = 20_000
+
+# CommonMark requires a space, tab or line end after the hashes: a non-breaking
+# space copied from rich text renders as text, not a heading, but a bare `##` is
+# an empty heading and still ends the release above.
+_HEADING_PATTERN = re.compile(r"^ {0,3}##(?:[ \t]+(?P.*?))?[ \t]*$")
+_FENCE_PATTERN = re.compile(r"^ {0,3}(?P`{3,}|~{3,})(?P.*)$")
+# CommonMark type 1 HTML blocks: contents are literal until a closing tag,
+# which the spec says need not be the one that opened the block.
+_RAW_HTML_OPEN = re.compile(r"^ {0,3}<(pre|script|style|textarea)(?=[\s>]|$)", re.IGNORECASE)
+_RAW_HTML_CLOSE = re.compile(r"(pre|script|style|textarea)\s*>", re.IGNORECASE)
+# Types 3 to 5 (processing instructions, declarations, CDATA) are literal too,
+# each ending on its own delimiter. Comments open mid-line, so are separate.
+_RAW_BLOCKS = (
+ (_RAW_HTML_OPEN, _RAW_HTML_CLOSE),
+ (re.compile(r"^ {0,3}<\?"), re.compile(r"\?>")),
+ (re.compile(r"^ {0,3}")),
+ # A declaration needs an uppercase letter, so `")),
+)
+# Type 6 blocks run to the next blank line, so `` only holds Markdown
+# once a blank line has closed the block. Open and close tags both start one.
+_HTML_BLOCK_OPEN = re.compile(r"^ {0,3}?([a-zA-Z][a-zA-Z0-9-]*)(?=[\s/>]|$)")
+# Blocks that break into an open paragraph, so none is open after them and one
+# they are written below is closed rather than continued.
+_INTERRUPTS = re.compile(
+ r"^ {0,3}(?:#{1,6}([ \t]|$)|(?:\*[ \t]*){3,}$|(?:-[ \t]*){3,}$|(?:_[ \t]*){3,}$)"
+)
+# A definition is a block of its own but may not interrupt a paragraph, so it
+# ends the one above it only when there is none to continue.
+_LINK_DEFINITION = re.compile(r"^ {0,3}\[(?:[^\[\]\\]|\\.)+\]:")
+# Blocks that are not paragraph text, so a following underline is not setext.
+_PARAGRAPH_TEXT = re.compile(r"^ {0,3}(?|\d{1,9}[.)]([ \t]|$))\S")
+# A line of = or - under a paragraph line makes that line a heading.
+_SETEXT_UNDERLINE = re.compile(r"^ {0,3}(=+|-+)[ \t]*$")
+# A quoted paragraph continues on unmarked lines, which belong to the quote.
+_BLOCK_QUOTE = re.compile(r"^ {0,3}>")
+_QUOTE_MARKER = re.compile(r"^ {0,3}>[ \t]?")
+# A heading at an item's content column belongs to that item, not the document.
+# The marker needs whitespace after it, so `2.0` is a version, not an item.
+_LIST_ITEM = re.compile(r"^[ \t]*(?P[-*+]|\d{1,9}[.)])(?P[ \t]+|$)")
+_THEMATIC_BREAK = re.compile(r"^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$")
+# Content indented more than this after a marker is an indented code block, so
+# the item's content starts one column past the marker instead.
+_MAX_ITEM_PADDING = 4
+_HTML_BLOCK_TAGS = frozenset(
+ """
+address article aside base basefont blockquote body caption center col colgroup
+dd details dialog dir div dl dt fieldset figcaption figure footer form frame
+frameset h1 h2 h3 h4 h5 h6 head header hr html iframe legend li link main menu
+menuitem nav noframes ol optgroup option p param search section summary table
+tbody td tfoot th thead title tr track ul
+""".split()
+)
+# Type 7: any other complete tag alone on a line. It cannot interrupt a
+# paragraph, so it only counts after a break.
+_HTML_ATTRIBUTE = (
+ r"""(?:\s+[a-zA-Z_:][a-zA-Z0-9_.:-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?)"""
+)
+_HTML_TAG_ONLY_LINE = re.compile(
+ rf"^ {{0,3}}(?:<[a-zA-Z][a-zA-Z0-9-]*{_HTML_ATTRIBUTE}*\s*/?>|[a-zA-Z][a-zA-Z0-9-]*\s*>)\s*$"
+)
+# Levels above studio/ are the repo root in a checkout and site-packages in an
+# install, so they are searched only when one of these markers is present.
+_CHECKOUT_ONLY_LEVELS = (3, 4)
+_CHECKOUT_MARKERS = ("pyproject.toml", ".git")
+_COMMENT_BLOCK_OPEN = re.compile(r"^ {0,3}"
+# Stands in for a line the renderer hides. `#` is a block of its own, so list
+# tracking reads it like a comment: never a marker, never a lazy continuation.
+_HIDDEN_BLOCK = "#"
+_VERSION_TOKEN_PATTERN = re.compile(r"^[\[(]?v?(?P[0-9][0-9A-Za-z.!+-]*?)[\])]?$")
+_SAFE_VERSION_PATTERN = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.!+-]{0,63}$")
+
+
+@dataclass(frozen = True)
+class _ListState:
+ """The open list items, innermost last, by the column their content starts."""
+
+ columns: tuple[int, ...] = ()
+ # True while the innermost item has had no content since its marker.
+ empty_item: bool = False
+
+
+@dataclass(frozen = True)
+class ChangelogEntry:
+ """One `## ` section of the changelog."""
+
+ version: str
+ heading: str
+ body: str
+
+
+@dataclass(frozen = True)
+class ChangelogSource:
+ text: str | None
+ source: str | None
+ error: str | None = None
+
+
+@dataclass
+class _ChangelogCacheEntry:
+ source: ChangelogSource
+ expires_at: float
+
+
+_cache_condition = threading.Condition()
+_remote_cache: _ChangelogCacheEntry | None = None
+_remote_fetching = False
+
+
+def reset_changelog_cache() -> None:
+ """Clear the in-process changelog cache. Intended for tests."""
+ global _remote_cache, _remote_fetching
+ with _cache_condition:
+ _remote_cache = None
+ _remote_fetching = False
+ _cache_condition.notify_all()
+
+
+def is_supported_version_query(version: str) -> bool:
+ """Whether `version` is shaped like something we can look up at all.
+
+ Sections are indexed only when their version parses, so a query that does
+ not parse (`latest`, `main`) can never match and is rejected outright."""
+ candidate = version.strip()
+ if not _SAFE_VERSION_PATTERN.match(candidate):
+ return False
+ return _parse_version(candidate) is not None
+
+
+def _markdown_lines(text: str) -> list[str]:
+ """``text`` split the way CommonMark ends lines.
+
+ str.splitlines also breaks on U+2028, U+2029, NEL, vertical tab and form
+ feed, none of which end a line in Markdown. A separator sitting in prose
+ before "## 9.9.9" would otherwise index a release the renderer never shows
+ and truncate the notes above it.
+ """
+ return text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
+
+
+def parse_changelog(text: str) -> list[ChangelogEntry]:
+ """Parse `## ` sections, in file order.
+
+ Headings whose first token is not a version (`## Unreleased`, `## Format`)
+ end the previous section but are not indexed.
+ """
+ # A Windows editor can leave a BOM on the first line, hiding a heading.
+ text = text.lstrip("")
+ entries: list[ChangelogEntry] = []
+ heading: str | None = None
+ version: str | None = None
+ body: list[str] = []
+ open_fence: str | None = None
+ # Content column of the list item the open block belongs to, 0 at document
+ # level. A fence and an HTML block are scoped to their container, so the
+ # item's end closes them. Only one of the three is ever open.
+ block_column = 0
+ in_comment = False
+ in_raw_html: int | None = None
+ in_html_block = False
+ after_paragraph = False
+ paragraph: list[str] = []
+ in_quote = False
+ quoted = False
+ lists = _ListState()
+
+ def flush() -> None:
+ if version is not None and heading is not None:
+ entries.append(
+ ChangelogEntry(
+ version = version,
+ heading = heading,
+ body = "\n".join(body).strip(),
+ )
+ )
+
+ for line in _markdown_lines(text):
+ # The line as list tracking sees it: blank wherever nothing renders.
+ structural = ""
+ opened_block = False
+ in_block = open_fence is not None or in_html_block or in_raw_html is not None or in_comment
+ # A fence, comment or HTML block inside a list item runs only to the end
+ # of that item, so a line dedented out of the item closes both. Lazy
+ # continuation reaches into none of them. A raw block or comment inside an
+ # item also ends on a blank line: the item takes the break, so what
+ # follows is a block of the item's own.
+ leaves = (
+ _indent_width(line) < block_column
+ if line.strip()
+ else in_raw_html is not None or in_comment
+ )
+ if in_block and block_column and leaves:
+ open_fence = None
+ in_html_block = False
+ in_raw_html = None
+ in_comment = False
+ block_column = 0
+ # The paragraph the line could have continued is block content, so
+ # it closes the item rather than reading as more of it.
+ after_paragraph = False
+ # A fence written as a list item's first content opens inside that item, so
+ # an opener is read past a marker on the same line. Only an opener: fenced
+ # content is literal and a closer carries no marker.
+ fence_line = line if open_fence else _item_content(line, after_paragraph)
+ # Raw HTML first: its contents are literal, so a fence in it is not one.
+ if in_raw_html is not None:
+ visible, in_raw_html = _strip_raw_html(line, in_raw_html)
+ elif in_html_block:
+ # A blank line is the only thing that ends a type 6 block.
+ in_html_block = line.strip() != ""
+ visible = ""
+ elif (fence := _FENCE_PATTERN.match(fence_line)) and not in_comment:
+ was_open = open_fence
+ open_fence = _next_fence_state(open_fence, fence.group("marker"), fence.group("rest"))
+ opened_block = was_open is None and open_fence is not None
+ # Hidden from heading matching, but its indent still closes items.
+ visible = ""
+ structural = line
+ elif open_fence:
+ visible = ""
+ else:
+ # A block already open owns this line, so it is content rather than a
+ # block written at the column it happens to start in.
+ hidden = in_comment or in_raw_html is not None
+ # A comment is an HTML block too, so one written as a list item's first
+ # content opens inside it exactly as a fence does: the opener is read
+ # past a marker on the same line.
+ block_open = (
+ not in_comment
+ and _COMMENT_BLOCK_OPEN.match(_item_content(line, after_paragraph)) is not None
+ )
+ # Commented-out sections are not rendered, so they are not releases.
+ visible, in_comment = _strip_comments(line, in_comment, block_open)
+ # An HTML block written as a list item's first content opens inside
+ # that item, as a fence does, so an opener is read past a marker on the
+ # same line. The marker stays, so its item is still tracked. A comment
+ # blanks its own line, so that line is read as written: the block
+ # renders as nothing, but the item it is content of still opens.
+ source = line if block_open else visible
+ content = _item_content(source, after_paragraph)
+ marker = source[: len(source) - len(content)]
+ # Nor is anything inside a raw HTML block such as
.
+ stripped, in_raw_html = _strip_raw_html(content, in_raw_html)
+ opened_block = in_raw_html is not None or (block_open and in_comment)
+ # Taken before the opener is hidden: it renders as nothing, but its
+ # indent still closes a list item it sits left of, and a marker on its
+ # line still opens one. A comment or raw block keeps only those, since
+ # the text it hides is not Markdown and must open no list.
+ if block_open or stripped != content:
+ if not hidden:
+ structural = _hidden_structure(line, marker)
+ visible = ""
+ else:
+ visible = marker + stripped
+ if visible.strip():
+ structural = visible
+ elif not hidden:
+ structural = _hidden_structure(line)
+ if stripped and _opens_html_block(stripped, after_paragraph):
+ in_html_block = True
+ opened_block = True
+ visible = ""
+ # A `##` inside a fenced block is sample markdown, not a real heading.
+ match = _HEADING_PATTERN.match(visible) if visible else None
+ # `1.0` over a line of dashes is the same heading written setext style.
+ setext = (
+ after_paragraph
+ and match is None
+ and paragraph != []
+ and _SETEXT_UNDERLINE.match(visible) is not None
+ and (visible.strip()[:1] == "-")
+ # Never a boundary inside a list item: dedented the dashes are a
+ # thematic break, and at the content column the heading is nested.
+ and not lists.columns
+ )
+ if setext:
+ if version is not None:
+ # The whole paragraph is the heading, read as body on arrival.
+ del body[len(body) - len(paragraph) :]
+ flush()
+ # A wrapped heading keeps every line, so token one is the version.
+ heading = "\n".join(paragraph)
+ version = _version_from_heading(heading)
+ body = []
+ paragraph = []
+ after_paragraph = False
+ continue
+ # A dashed underline is not a list marker, so track lists after setext.
+ lazy_marker = _lazy_marker(structural, lists, after_paragraph, quoted)
+ lists = _open_lists(structural, lists, after_paragraph, quoted)
+ # Taken after the opening line closed the items it is dedented out of,
+ # so the block belongs to the item it is really written inside.
+ if opened_block:
+ block_column = lists.columns[-1] if lists.columns else 0
+ elif open_fence is None and not in_html_block and in_raw_html is None and not in_comment:
+ block_column = 0
+ # At an open item's content column a heading is nested, not a boundary.
+ if lists.columns and _indent_width(visible) >= lists.columns[0]:
+ match = None
+ # The line at its own nesting level: past the container's indentation
+ # and past a marker on the same line, so `- ## 2.0` reads as a heading.
+ column = lists.columns[-1] if lists.columns else 0
+ content = _strip_indent(visible, column)
+ if (item := _LIST_ITEM.match(content)) is not None:
+ content = content[item.end() :]
+ # Only ordinary text continues a paragraph. Indented code counts four
+ # spaces past the container, so an item's own indent does not count.
+ indented_code = not after_paragraph and _indent_width(visible) - column >= 4
+ # An underline ends the paragraph it underlines, so it needs one open in
+ # its own container: the quote above owns its own, and a row left of an
+ # open item is lazy text of the item's paragraph. Three dashes are a
+ # thematic break either way, which `_INTERRUPTS` already ends on.
+ underline = (
+ _SETEXT_UNDERLINE.match(visible) is not None
+ and after_paragraph
+ and not quoted
+ and _indent_width(visible) >= column
+ )
+ after_paragraph = (
+ # Read inside its container, so an empty item and a fence written as an
+ # item's own content leave no paragraph open below them. A marker the
+ # paragraph above swallows is its text, not an item.
+ (bool(content.strip()) or lazy_marker)
+ and match is None
+ and _HEADING_PATTERN.match(content) is None
+ and _FENCE_PATTERN.match(content) is None
+ and not indented_code
+ and _INTERRUPTS.match(visible) is None
+ and (after_paragraph or _LINK_DEFINITION.match(visible) is None)
+ and not underline
+ )
+ # A quote's paragraph runs on over plain text and owns every line of it.
+ # An empty quote holds none, so the line below starts the document's.
+ flush_left = visible.lstrip(" \t")
+ quote_line = _BLOCK_QUOTE.match(visible) is not None
+ in_quote = (
+ _may_be_lazy(_quote_content(visible))
+ if quote_line
+ else in_quote and _continues_paragraph(visible, column)
+ )
+ if quote_line:
+ # The only paragraph a quote line leaves open is the quote's own,
+ # and a quote holding a heading or nothing at all leaves none.
+ after_paragraph = in_quote
+ # Whose paragraph the line below would continue. A quote owns the one its
+ # own lines hold, so a marker outside the quote is a block of its own
+ # rather than more of the text above it.
+ quoted = quote_line or in_quote
+ # The lines a later underline turns into one heading. A paragraph opens
+ # only on plain text and then runs on until something interrupts it.
+ continues = (
+ not _interrupts_paragraph(flush_left)
+ if paragraph
+ else _PARAGRAPH_TEXT.match(flush_left) is not None
+ )
+ # A paragraph inside an open item is that item's, and only one written
+ # at document level can be the heading a later underline makes of it.
+ if after_paragraph and not in_quote and not lists.columns and continues:
+ paragraph = [*paragraph, visible.strip()]
+ else:
+ paragraph = []
+ if match is None:
+ if version is not None:
+ body.append(line)
+ continue
+
+ flush()
+ # An empty heading has no title, so it ends the release above without
+ # indexing one: `_version_from_heading` finds no version and `flush` skips.
+ heading = match.group("title") or ""
+ version = _version_from_heading(heading)
+ body = []
+
+ flush()
+ return entries
+
+
+def find_release_notes(text: str, version: str) -> ChangelogEntry | None:
+ """Return the section for exactly `version`, or None.
+
+ Equality is version-aware (`2026.07.5` matches `2026.7.5`) but never fuzzy:
+ a near-miss returns None so the caller shows no notes, not the wrong ones.
+ """
+ entries = parse_changelog(text)
+ for entry in entries:
+ # An exact heading wins, so `## 1.0` is never shadowed by `## 1.0.0`.
+ if entry.version == version:
+ return entry
+
+ wanted = _parse_version(version)
+ for entry in entries:
+ if wanted is not None:
+ candidate = _parse_version(entry.version)
+ if candidate is not None and candidate == wanted:
+ return entry
+ return None
+
+
+def get_release_notes(version: str, refresh: bool = False) -> dict[str, Any]:
+ """Return release notes for exactly `version` for the update popup.
+
+ `refresh` retries a cached remote failure, so the UI's retry action is not
+ stuck behind the failure TTL once connectivity returns.
+ """
+ version = version.strip()
+ if not is_supported_version_query(version):
+ return _notes_response(version = version, error = "Unsupported version.")
+
+ local = _read_local_changelog()
+ remote = ChangelogSource(text = None, source = None)
+ if os.environ.get(DISABLE_ENV_VAR) != "1":
+ remote = get_remote_changelog(refresh = refresh)
+
+ # Remote first: the offered version is newer than the local copy.
+ for candidate in (remote, local):
+ if not candidate.text:
+ continue
+ entry = find_release_notes(candidate.text, version)
+ if entry is not None:
+ return _notes_response(
+ version = version,
+ markdown = entry.body,
+ heading = entry.heading,
+ source = candidate.source,
+ )
+
+ # Nothing matched: the bundled copy cannot know a version newer than the
+ # install, so report a remote failure and let the UI offer a retry.
+ return _notes_response(version = version, error = remote.error)
+
+
+def get_remote_changelog(refresh: bool = False) -> ChangelogSource:
+ """Fetch CHANGELOG.md from the repo using a small in-process TTL cache."""
+ global _remote_cache, _remote_fetching
+
+ if refresh:
+ # Only a cached failure is dropped, so retries cannot hammer the remote.
+ with _cache_condition:
+ if _remote_cache and _remote_cache.source.text is None:
+ _remote_cache = None
+
+ # A caller waits for an in-flight fetch only as long as it may take, then
+ # answers locally rather than holding a worker behind a stalled upstream.
+ deadline = time.monotonic() + CHANGELOG_TIMEOUT_SECONDS + 1
+ while True:
+ now = time.monotonic()
+ with _cache_condition:
+ if _remote_cache and _remote_cache.expires_at > now:
+ return _remote_cache.source
+ if not _remote_fetching:
+ _remote_fetching = True
+ break
+ if now >= deadline:
+ return ChangelogSource(
+ text = None,
+ source = None,
+ error = "Release notes are still loading.",
+ )
+ _cache_condition.wait(timeout = deadline - now)
+
+ try:
+ try:
+ source = _fetch_remote_changelog()
+ except Exception:
+ source = ChangelogSource(
+ text = None,
+ source = None,
+ error = "Could not fetch release notes.",
+ )
+
+ ttl = CHANGELOG_SUCCESS_TTL_SECONDS if source.text else CHANGELOG_FAILURE_TTL_SECONDS
+ with _cache_condition:
+ _remote_cache = _ChangelogCacheEntry(source = source, expires_at = time.monotonic() + ttl)
+ return source
+ finally:
+ # Released here, not on the Exception path: stranding the single-flight
+ # flag on BaseException makes every later caller wait out the deadline.
+ with _cache_condition:
+ _remote_fetching = False
+ _cache_condition.notify_all()
+
+
+def _fetch_remote_changelog() -> ChangelogSource:
+ url = os.environ.get(CHANGELOG_URL_ENV_VAR, "").strip() or CHANGELOG_RAW_URL
+ if not url.startswith(("http://", "https://")):
+ return ChangelogSource(text = None, source = None, error = "Invalid changelog URL.")
+
+ request = urllib.request.Request(
+ url,
+ headers = {
+ "User-Agent": "unsloth-studio-update-check",
+ # Or a compressing proxy hands back bytes we would decode as notes.
+ "Accept-Encoding": "identity",
+ },
+ )
+ deadline = time.monotonic() + CHANGELOG_TIMEOUT_SECONDS
+ try:
+ with urllib.request.urlopen(request, timeout = CHANGELOG_TIMEOUT_SECONDS) as response:
+ chunks: list[bytes] = []
+ received = 0
+ while received <= CHANGELOG_MAX_BYTES:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ return ChangelogSource(
+ text = None,
+ source = None,
+ error = "Release notes took too long to load.",
+ )
+ # The socket timeout is per operation, so re-cap it each read.
+ _limit_read(response, remaining)
+ chunk = response.read1(_CHANGELOG_CHUNK_BYTES)
+ if not chunk:
+ break
+ chunks.append(chunk)
+ received += len(chunk)
+ body = b"".join(chunks)
+ if len(body) > CHANGELOG_MAX_BYTES:
+ return ChangelogSource(
+ text = None,
+ source = None,
+ error = "Release notes response was too large.",
+ )
+ return ChangelogSource(text = body.decode("utf-8", errors = "replace"), source = "remote")
+ except TimeoutError:
+ return ChangelogSource(
+ text = None,
+ source = None,
+ error = "Release notes took too long to load.",
+ )
+ except OSError:
+ return ChangelogSource(
+ text = None,
+ source = None,
+ error = "Could not reach the changelog for release notes.",
+ )
+ except UnicodeError:
+ return ChangelogSource(text = None, source = None, error = "Malformed changelog.")
+
+
+def _limit_read(response: Any, remaining: float) -> None:
+ """Cap the next socket read at the time left in the fetch budget."""
+ sock = getattr(getattr(response, "fp", None), "raw", None)
+ sock = getattr(sock, "_sock", None)
+ if sock is None:
+ return
+ try:
+ sock.settimeout(max(remaining, _CHANGELOG_MIN_READ_SECONDS))
+ except OSError:
+ pass
+
+
+def _read_local_changelog() -> ChangelogSource:
+ """Read the CHANGELOG.md bundled with this install, if there is one."""
+ for path in _local_changelog_candidates():
+ try:
+ if not path.is_file():
+ continue
+ if path.stat().st_size > CHANGELOG_MAX_BYTES:
+ continue
+ return ChangelogSource(
+ text = path.read_text(encoding = "utf-8", errors = "replace"),
+ source = "local",
+ )
+ except OSError:
+ continue
+ return ChangelogSource(text = None, source = None)
+
+
+def _is_source_checkout(root: Path) -> bool:
+ """Whether `root` is this repository rather than an install directory."""
+ try:
+ return any((root / marker).exists() for marker in _CHECKOUT_MARKERS)
+ except OSError:
+ return False
+
+
+def _local_changelog_candidates() -> list[Path]:
+ override = os.environ.get(CHANGELOG_PATH_ENV_VAR, "").strip()
+ candidates: list[Path] = []
+ if override:
+ candidates.append(Path(override).expanduser())
+
+ # changelog.py -> utils -> backend -> studio -> repo root. Repo root first
+ # so a checkout's editable file beats the snapshot packaging writes into
+ # studio/. Installed, those outer levels are site-packages, hence the marker.
+ parents = Path(__file__).resolve().parents
+ for index in (3, 2, 1, 4):
+ if index >= len(parents):
+ continue
+ root = parents[index]
+ if index in _CHECKOUT_ONLY_LEVELS and not _is_source_checkout(root):
+ continue
+ candidates.append(root / CHANGELOG_FILENAME)
+
+ seen: set[Path] = set()
+ unique: list[Path] = []
+ for candidate in candidates:
+ if candidate not in seen:
+ seen.add(candidate)
+ unique.append(candidate)
+ return unique
+
+
+def _opens_fence(marker: str, rest: str) -> bool:
+ """A backtick fence's info string may not contain a backtick."""
+ return marker[0] != "`" or "`" not in rest
+
+
+def _next_fence_state(open_fence: str | None, marker: str, rest: str) -> str | None:
+ """Track the open fence marker.
+
+ A closer must be the same character, at least as long, and carry nothing
+ after it. So neither a ``` sample nor a ```` line with trailing text ends
+ a ```` block early, while an opening fence may still have an info string.
+ Only spaces and tabs count as nothing: other Unicode whitespace is content.
+ """
+ if open_fence is None:
+ return marker if _opens_fence(marker, rest) else None
+ closes = marker[0] == open_fence[0] and len(marker) >= len(open_fence)
+ if closes and not rest.strip(" \t"):
+ return None
+ return open_fence
+
+
+def _code_span_ranges(line: str) -> list[tuple[int, int]]:
+ """Code span bounds. A run of backticks closes only on a run of its length."""
+ # Collect the runs once: rescanning per opener is quadratic on a line of
+ # distinct unmatched runs, and notes are reparsed on every request.
+ runs: list[tuple[int, int]] = []
+ index = 0
+ while index < len(line):
+ if line[index] != "`" or _is_escaped(line, index):
+ index += 1
+ continue
+ ticks = _run_length(line, index)
+ runs.append((index, ticks))
+ index += ticks
+
+ # A run closes only on a later run of its length, so one cursor per length.
+ by_length: dict[int, list[int]] = {}
+ for position, (_, ticks) in enumerate(runs):
+ by_length.setdefault(ticks, []).append(position)
+
+ spans: list[tuple[int, int]] = []
+ cursors: dict[int, int] = {}
+ current = 0
+ while current < len(runs):
+ start, ticks = runs[current]
+ same = by_length[ticks]
+ cursor = cursors.get(ticks, 0)
+ while cursor < len(same) and same[cursor] <= current:
+ cursor += 1
+ cursors[ticks] = cursor
+ if cursor >= len(same):
+ # Nothing closes this run, so it is literal text.
+ current += 1
+ continue
+ closer = same[cursor]
+ cursors[ticks] = cursor + 1
+ spans.append((start, runs[closer][0] + ticks))
+ current = closer + 1
+ return spans
+
+
+def _run_length(line: str, index: int) -> int:
+ end = index
+ while end < len(line) and line[end] == "`":
+ end += 1
+ return end - index
+
+
+def _is_escaped(line: str, index: int) -> bool:
+ slashes = 0
+ while index - 1 - slashes >= 0 and line[index - 1 - slashes] == "\\":
+ slashes += 1
+ return slashes % 2 == 1
+
+
+def _strip_comments(line: str, in_comment: bool, block_open: bool) -> tuple[str, bool]:
+ """Return the line with HTML-comment spans removed, and the trailing state.
+
+ Only a comment that starts a line opens a block and hides the lines below
+ it. One written mid-sentence is inline HTML: it hides the rest of its own
+ line at most, so a note mentioning `` and `` are complete comments, so the closer may overlap
+ # the opener; searching past it would swallow every later release.
+ return ("", _COMMENT_CLOSE not in line)
+
+ visible: list[str] = []
+ index = 0
+ spans = _code_span_ranges(line)
+ # Spans are ordered and disjoint and each opener sits at or past the one
+ # before, so the search resumes rather than restarts: restarting per opener is
+ # quadratic, and a long line of code spans is reparsed on every request.
+ cursor = 0
+ while index < len(line):
+ opening = line.find(_COMMENT_OPEN, index)
+ if opening == -1:
+ visible.append(line[index:])
+ break
+
+ while cursor < len(spans) and spans[cursor][1] <= opening:
+ cursor += 1
+ if cursor < len(spans) and spans[cursor][0] <= opening:
+ visible.append(line[index : spans[cursor][1]])
+ index = spans[cursor][1]
+ continue
+
+ visible.append(line[index:opening])
+ close = line.find(_COMMENT_CLOSE, opening + len(_COMMENT_OPEN))
+ if close == -1:
+ # Unterminated inline comment: it hides this line and no more.
+ break
+ index = close + len(_COMMENT_CLOSE)
+ return "".join(visible), False
+
+
+def _hidden_structure(line: str, marker: str = "") -> str:
+ """`line` as list tracking sees it once the renderer hides its text.
+
+ A comment or a raw HTML block renders nothing, but it is still a block
+ written at its own column, so it closes the items it sits to the left of.
+ Only the indentation survives: what is inside the block is not Markdown and
+ must not open a list of its own. `marker` is the part of the line that opens
+ a list item the block is the content of, which survives with it."""
+ if marker:
+ return marker + _HIDDEN_BLOCK
+ if not line.strip():
+ return ""
+ return line[: len(line) - len(line.lstrip(" \t"))] + _HIDDEN_BLOCK
+
+
+def _indent_width(line: str) -> int:
+ """Columns of leading whitespace, counting a tab to the next stop of four."""
+ width = 0
+ for char in line:
+ if char == " ":
+ width += 1
+ elif char == "\t":
+ width += 4 - width % 4
+ else:
+ break
+ return width
+
+
+def _strip_indent(line: str, columns: int) -> str:
+ """`line` with up to `columns` columns of leading whitespace removed."""
+ width = 0
+ index = 0
+ while index < len(line) and width < columns and line[index] in " \t":
+ width += 1 if line[index] == " " else 4 - width % 4
+ index += 1
+ return line[index:]
+
+
+def _interrupts_paragraph(line: str) -> bool:
+ """Whether `line` starts a block that can break into an open paragraph.
+
+ A quote marker always can. A list item can only when it has content, and an
+ ordered one only when it starts at 1: anything else is text of the
+ paragraph it appears to interrupt."""
+ if _BLOCK_QUOTE.match(line):
+ return True
+ item = None if _THEMATIC_BREAK.match(line) else _LIST_ITEM.match(line)
+ if item is None:
+ return False
+ marker = item.group("marker")
+ if not line[item.end() :].strip():
+ return False
+ return marker[-1] not in ".)" or marker[:-1] == "1"
+
+
+def _item_content(line: str, after_paragraph: bool) -> str:
+ """`line` read from the content column of a list item that opens on it.
+
+ A block written as an item's first content sits inside that item, so
+ ``- ```` opens a fence even though its marker is not within three columns of
+ the container. The padding is capped the way `_open_lists` caps it, or
+ ``- ```` would read as a fence rather than the indented code it is. A
+ marker the paragraph above swallows opens no item, so its line is returned
+ whole, as is one four columns past its container. Ported to the frontend as
+ `itemContent` in markdown-list-columns.ts."""
+ if _indent_width(line) >= 4 or (after_paragraph and not _interrupts_paragraph(line)):
+ return line
+ item = None if _THEMATIC_BREAK.match(line) else _LIST_ITEM.match(line)
+ if item is None:
+ return line
+ padding = _indent_width(item.group("space"))
+ # Over-indented content starts one column past the marker; the rest of the
+ # padding is the content's own indentation.
+ over = padding - 1 if padding > _MAX_ITEM_PADDING else 0
+ return " " * over + line[item.end() :]
+
+
+def _quote_content(line: str) -> str:
+ """What a blockquote line holds, with its markers stripped."""
+ while (marker := _QUOTE_MARKER.match(line)) is not None:
+ line = line[marker.end() :]
+ return line
+
+
+def _may_be_lazy(line: str) -> bool:
+ """Whether `line` can continue a paragraph it is indented out of.
+
+ Only plain text can: a heading, a fence, a break or an HTML block starts a
+ block of its own, which closes the item instead. An underline is not one of
+ them: it may never be lazy, so `===` written left of an open item is read as
+ more of the item's paragraph. Nor is a definition, which is a block of its
+ own but may not interrupt a paragraph. A row of dashes still closes the
+ item, as `_INTERRUPTS` reads three or more as the thematic break they are."""
+ return (
+ _PARAGRAPH_TEXT.match(line) is not None
+ and _INTERRUPTS.match(line) is None
+ and _FENCE_PATTERN.match(line) is None
+ # Types 1 to 6 interrupt a paragraph, so a `
` left of an open item
+ # closes it. Type 7 cannot, and is deliberately excluded.
+ and not _opens_html_block(line, True)
+ )
+
+
+def _continues_paragraph(line: str, column: int) -> bool:
+ """Whether `line` reads as more of a paragraph open in its container.
+
+ Measured from `column`, where that container's content starts: four columns
+ past it the line is an indented code block, which may not interrupt a
+ paragraph, so indentation alone never closes the one above it."""
+ inner = _strip_indent(line, column)
+ return _indent_width(inner) >= 4 or _may_be_lazy(inner)
+
+
+def _close_dedented(
+ columns: tuple[int, ...], line: str, indent: int, after_paragraph: bool
+) -> tuple[int, ...]:
+ """`columns` with every item `line` is written to the left of closed.
+
+ Read inside the container the item sits in, not from the margin: a line that
+ only looks indented there is lazy text of the item's paragraph, which leaves
+ the item open rather than closing it."""
+ while columns and indent < columns[-1]:
+ outer = columns[-2] if len(columns) > 1 else 0
+ if after_paragraph and _continues_paragraph(line, outer):
+ break
+ columns = columns[:-1]
+ return columns
+
+
+def _lazy_marker(line: str, state: _ListState, after_paragraph: bool, quoted: bool) -> bool:
+ """Whether a marker-shaped `line` is really text of the paragraph above it.
+
+ Only a marker inside the paragraph's own item interrupts it; one to the left
+ closes that item and opens a sibling. A quote owns the paragraph its lines
+ hold, so a marker written outside the quote opens a list of its own."""
+ item = None if _THEMATIC_BREAK.match(line) else _LIST_ITEM.match(line)
+ columns = state.columns
+ return (
+ item is not None
+ and after_paragraph
+ and not quoted
+ and (not columns or _indent_width(line) >= columns[-1])
+ and not _interrupts_paragraph(line)
+ )
+
+
+def _open_lists(
+ line: str,
+ state: _ListState,
+ after_paragraph: bool,
+ quoted: bool = False,
+) -> _ListState:
+ """The list items still open after `line`.
+
+ A dedented line closes an item, unless it is a lazy paragraph continuation.
+ A new marker nests under a deeper column and replaces a sibling. `quoted`
+ marks a paragraph the blockquote above owns: a marker written outside the
+ quote is not text of it, so it opens a list of its own.
+ """
+ columns = state.columns
+ if not line.strip():
+ # A blank line leaves the list open, unless the item is still empty: an
+ # item may begin with one blank line, and later content is outside it.
+ return _ListState(columns[:-1] if state.empty_item else columns)
+ indent = _indent_width(line)
+ item = None if _THEMATIC_BREAK.match(line) else _LIST_ITEM.match(line)
+ empty = item is not None and not line[item.end() :].strip()
+ if _lazy_marker(line, state, after_paragraph, quoted):
+ # A lazy continuation or an underline, so the open items are untouched.
+ return state
+ columns = _close_dedented(columns, line, indent, after_paragraph)
+ # Four columns past its container the marker is an indented code block, or
+ # lazy text of the paragraph above it, so it opens no list of its own.
+ if item is None or indent - (columns[-1] if columns else 0) >= 4:
+ return _ListState(columns)
+ marker = item.group("marker")
+ padding = _indent_width(item.group("space"))
+ if padding == 0 or padding > _MAX_ITEM_PADDING:
+ # An empty or over-indented item still holds one column of content.
+ padding = 1
+ while columns and columns[-1] > indent:
+ columns = columns[:-1]
+ return _ListState((*columns, indent + len(marker) + padding), empty_item = empty)
+
+
+def _opens_html_block(line: str, after_paragraph: bool) -> bool:
+ """True if `line` starts a CommonMark type 6 or type 7 HTML block."""
+ match = _HTML_BLOCK_OPEN.match(line)
+ if match is not None and match.group(1).lower() in _HTML_BLOCK_TAGS:
+ return True
+ return not after_paragraph and _HTML_TAG_ONLY_LINE.match(line) is not None
+
+
+def _strip_raw_html(line: str, open_block: int | None) -> tuple[str, int | None]:
+ """Drop the parts of a line inside a raw block, and return the open block.
+
+ The state is the index of the open block in `_RAW_BLOCKS`, or None."""
+ if open_block is not None:
+ close = _RAW_BLOCKS[open_block][1].search(line)
+ return ("", None) if close else ("", open_block)
+
+ # A block only opens at the start of a line; mid-line tags are inline HTML.
+ for index, (opener, closer) in enumerate(_RAW_BLOCKS):
+ opening = opener.match(line)
+ if opening is None:
+ continue
+ rest = line[opening.end() :]
+ close = closer.search(rest)
+ return ("", None) if close else ("", index)
+ return line, None
+
+
+def _version_from_heading(heading: str) -> str | None:
+ token = heading.split()[0] if heading.split() else ""
+ match = _VERSION_TOKEN_PATTERN.match(token)
+ if match is None:
+ return None
+ version = match.group("version")
+ return version if _parse_version(version) is not None else None
+
+
+def _parse_version(version: str) -> Version | None:
+ try:
+ return Version(version)
+ except InvalidVersion:
+ return None
+
+
+def _close_open_fence(markdown: str) -> str:
+ """Close a fence the truncation cut in half, so the rest still renders."""
+ open_fence: str | None = None
+ for line in _markdown_lines(markdown):
+ fence = _FENCE_PATTERN.match(line)
+ if fence:
+ open_fence = _next_fence_state(open_fence, fence.group("marker"), fence.group("rest"))
+ return f"{markdown}\n{open_fence}" if open_fence else markdown
+
+
+def _renders_visibly(markdown: str) -> bool:
+ """Whether a section body renders anything at all."""
+ in_comment = False
+ for line in _markdown_lines(markdown):
+ opens_raw = any(opener.match(line) for opener, _ in _RAW_BLOCKS)
+ if not in_comment and (_FENCE_PATTERN.match(line) or opens_raw):
+ # A code block or raw HTML block renders even when it is empty.
+ return True
+ # No containers are tracked here, so the opener is read at the margin. The
+ # answer does not turn on it: an item renders its marker whatever the block
+ # inside hides, so a commented-out item renders something either way.
+ visible, in_comment = _strip_comments(
+ line, in_comment, _COMMENT_BLOCK_OPEN.match(line) is not None
+ )
+ if visible.strip():
+ return True
+ return False
+
+
+def _notes_response(
+ *,
+ version: str,
+ markdown: str | None = None,
+ heading: str | None = None,
+ source: str | None = None,
+ error: str | None = None,
+) -> dict[str, Any]:
+ # A section that renders as nothing counts as unpublished, not as empty.
+ if markdown and not _renders_visibly(markdown):
+ markdown = None
+ source = None
+
+ truncated = False
+ if markdown and len(markdown) > RELEASE_NOTES_MAX_CHARS:
+ markdown = _close_open_fence(markdown[:RELEASE_NOTES_MAX_CHARS].rstrip())
+ truncated = True
+
+ return {
+ "version": version,
+ "markdown": markdown or None,
+ "heading": heading,
+ # False means no notes for this exact version; the UI links out.
+ "matched": bool(markdown),
+ "truncated": truncated,
+ "source": source,
+ "release_notes_url": RELEASE_NOTES_URL,
+ "error": error,
+ }
diff --git a/studio/backend/utils/child_stdio.py b/studio/backend/utils/child_stdio.py
new file mode 100644
index 0000000000..4709d650df
--- /dev/null
+++ b/studio/backend/utils/child_stdio.py
@@ -0,0 +1,22 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Make a Python child agree with the parent that its pipes are UTF-8.
+
+A child's ``sys.stdout`` uses ``locale.getpreferredencoding()``, which on
+Windows is the ANSI code page. Reading that pipe as UTF-8 would then mangle any
+non-ASCII the child prints, so the child has to be told which encoding to emit.
+Only needed for Python children; llama.cpp and node already emit UTF-8.
+"""
+
+from __future__ import annotations
+
+import os
+from typing import Mapping, Optional
+
+
+def utf8_child_env(env: Optional[Mapping[str, str]] = None) -> dict[str, str]:
+ """Copy *env* (or the current environment) with UTF-8 stdio forced."""
+ child = dict(os.environ if env is None else env)
+ child["PYTHONIOENCODING"] = "utf-8"
+ return child
diff --git a/studio/backend/utils/hardware/amd.py b/studio/backend/utils/hardware/amd.py
index 91a06c9a2a..318759f67d 100644
--- a/studio/backend/utils/hardware/amd.py
+++ b/studio/backend/utils/hardware/amd.py
@@ -144,6 +144,8 @@ def _run_amd_smi(*args: str, timeout: int = _AMD_SMI_DEFAULT_TIMEOUT) -> Optiona
["amd-smi", *args, "--json"],
capture_output = True,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = timeout,
env = _amd_env,
**windows_hidden_subprocess_kwargs(),
diff --git a/studio/backend/utils/hardware/hardware.py b/studio/backend/utils/hardware/hardware.py
index 48ba375ec5..300d26c362 100644
--- a/studio/backend/utils/hardware/hardware.py
+++ b/studio/backend/utils/hardware/hardware.py
@@ -830,6 +830,8 @@ def _rocm_windows_perf_counter_gpu_util_pct() -> Optional[float]:
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
capture_output = True,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = 5,
)
if r.returncode != 0 or not r.stdout.strip():
@@ -1027,6 +1029,8 @@ def _rocm_windows_perf_counter_vram_by_adapter() -> Optional[list[tuple[str, flo
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
capture_output = True,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = 5,
)
if r.returncode != 0 or not r.stdout.strip():
diff --git a/studio/backend/utils/hardware/nvidia.py b/studio/backend/utils/hardware/nvidia.py
index f98ca4343e..39e3652921 100644
--- a/studio/backend/utils/hardware/nvidia.py
+++ b/studio/backend/utils/hardware/nvidia.py
@@ -55,6 +55,8 @@ def get_physical_gpu_count() -> Optional[int]:
["nvidia-smi", "-L"],
capture_output = True,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = 5,
env = child_env_without_native_path_secret(),
**_windows_hidden_subprocess_kwargs(),
@@ -81,6 +83,8 @@ def get_primary_gpu_utilization() -> dict[str, Any]:
],
capture_output = True,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = 5,
env = child_env_without_native_path_secret(),
**_windows_hidden_subprocess_kwargs(),
@@ -131,6 +135,8 @@ def get_visible_gpu_utilization(
],
capture_output = True,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = 5,
env = child_env_without_native_path_secret(),
**_windows_hidden_subprocess_kwargs(),
@@ -215,6 +221,8 @@ def get_backend_visible_gpu_info(
],
capture_output = True,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = 10,
env = child_env_without_native_path_secret(),
**_windows_hidden_subprocess_kwargs(),
diff --git a/studio/backend/utils/llama_cpp_update.py b/studio/backend/utils/llama_cpp_update.py
index dffcddb452..5c9646f4eb 100644
--- a/studio/backend/utils/llama_cpp_update.py
+++ b/studio/backend/utils/llama_cpp_update.py
@@ -121,7 +121,14 @@ def _installed_build_number(binary: Optional[str]) -> Optional[int]:
if not binary:
return None
try:
- proc = subprocess.run([binary, "--version"], capture_output = True, text = True, timeout = 20)
+ proc = subprocess.run(
+ [binary, "--version"],
+ capture_output = True,
+ text = True,
+ encoding = "utf-8",
+ errors = "replace",
+ timeout = 20,
+ )
except Exception: # pragma: no cover - defensive
return None
m = re.search(r"version:\s*(\d+)", (proc.stderr or "") + (proc.stdout or ""))
diff --git a/studio/backend/utils/mlx_repair.py b/studio/backend/utils/mlx_repair.py
index 4ea1ec62f5..8e2a6a7712 100644
--- a/studio/backend/utils/mlx_repair.py
+++ b/studio/backend/utils/mlx_repair.py
@@ -254,7 +254,7 @@ def _transformers_constraint_args() -> tuple[list[str], str | None]:
except Exception:
return [], None
fd, path = tempfile.mkstemp(prefix = "mlx_repair_", suffix = ".txt")
- with os.fdopen(fd, "w") as fh:
+ with os.fdopen(fd, "w", encoding = "utf-8") as fh:
fh.write(f"transformers=={transformers_version}\n")
return ["--constraint", path], path
@@ -290,6 +290,8 @@ def attempt_mlx_repair(*, timeout: int = _REPAIR_TIMEOUT_S) -> bool:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = timeout,
)
except subprocess.TimeoutExpired:
diff --git a/studio/backend/utils/models/checkpoints.py b/studio/backend/utils/models/checkpoints.py
index 6950667bbd..eaf75140fc 100644
--- a/studio/backend/utils/models/checkpoints.py
+++ b/studio/backend/utils/models/checkpoints.py
@@ -129,7 +129,7 @@ def _read_checkpoint_loss(checkpoint_path: Path) -> Optional[float]:
if not trainer_state.exists():
return None
try:
- with open(trainer_state, encoding = "utf-8") as f:
+ with open(trainer_state, encoding = "utf-8-sig") as f:
state = json.load(f)
log_history = state.get("log_history", [])
if log_history:
@@ -174,18 +174,18 @@ def scan_checkpoints(
metadata: dict = {}
try:
if adapter_config.exists():
- cfg = json.loads(adapter_config.read_text(encoding = "utf-8"))
+ cfg = json.loads(adapter_config.read_text(encoding = "utf-8-sig"))
metadata["base_model"] = cfg.get("base_model_name_or_path")
metadata["peft_type"] = cfg.get("peft_type")
metadata["lora_rank"] = cfg.get("r")
elif config_file.exists():
- cfg = json.loads(config_file.read_text(encoding = "utf-8"))
+ cfg = json.loads(config_file.read_text(encoding = "utf-8-sig"))
metadata["base_model"] = cfg.get("_name_or_path")
# Detect BNB quantization from config.json
if config_file.exists():
if "cfg" not in dir():
- cfg = json.loads(config_file.read_text(encoding = "utf-8"))
+ cfg = json.loads(config_file.read_text(encoding = "utf-8-sig"))
quant_cfg = cfg.get("quantization_config")
if (
isinstance(quant_cfg, dict)
diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py
index 893b842e11..6270d9e03f 100644
--- a/studio/backend/utils/models/model_config.py
+++ b/studio/backend/utils/models/model_config.py
@@ -37,6 +37,7 @@ import yaml
from utils.native_path_leases import child_env_without_native_path_secret
+from utils.child_stdio import utf8_child_env
from utils.hf_cache_settings import active_hf_hub_cache, get_hf_cache_paths
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
@@ -631,7 +632,7 @@ def _raw_config_has_vision_config(
cache_dir = active_hf_hub_cache(),
)
)
- config = json.loads(config_path.read_text(encoding = "utf-8"))
+ config = json.loads(config_path.read_text(encoding = "utf-8-sig"))
architectures = config.get("architectures") or []
model_type = config.get("model_type")
explicit_vision = (
@@ -774,8 +775,12 @@ def _is_vision_model_subprocess(model_name: str, hf_token: Optional[str] = None)
],
capture_output = True,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = 60,
- env = get_hf_cache_paths().child_env(child_env_without_native_path_secret()),
+ env = utf8_child_env(
+ get_hf_cache_paths().child_env(child_env_without_native_path_secret())
+ ),
**_windows_hidden_subprocess_kwargs(),
)
@@ -1083,7 +1088,7 @@ def _detect_audio_from_tokenizer(
]:
tok_file = snapshot / tok_path
if tok_file.exists():
- tok_config = json.loads(tok_file.read_text(encoding = "utf-8"))
+ tok_config = json.loads(tok_file.read_text(encoding = "utf-8-sig"))
read_any = True
result = _check_token_patterns(tok_config)
if result:
@@ -2283,7 +2288,7 @@ def scan_exported_models(
export_meta = run_dir / "export_metadata.json"
try:
if export_meta.exists():
- meta = json.loads(export_meta.read_text(encoding = "utf-8"))
+ meta = json.loads(export_meta.read_text(encoding = "utf-8-sig"))
base_model = meta.get("base_model")
except Exception:
pass
@@ -2312,7 +2317,7 @@ def scan_exported_models(
if adapter_config.exists():
export_type = "lora"
try:
- cfg = json.loads(adapter_config.read_text(encoding = "utf-8"))
+ cfg = json.loads(adapter_config.read_text(encoding = "utf-8-sig"))
base_model = cfg.get("base_model_name_or_path")
except Exception:
pass
@@ -2321,7 +2326,7 @@ def scan_exported_models(
export_meta = checkpoint_dir / "export_metadata.json"
try:
if export_meta.exists():
- meta = json.loads(export_meta.read_text(encoding = "utf-8"))
+ meta = json.loads(export_meta.read_text(encoding = "utf-8-sig"))
base_model = meta.get("base_model")
except Exception:
pass
@@ -2334,7 +2339,7 @@ def scan_exported_models(
export_meta = meta_dir / "export_metadata.json"
try:
if export_meta.exists():
- meta = json.loads(export_meta.read_text(encoding = "utf-8"))
+ meta = json.loads(export_meta.read_text(encoding = "utf-8-sig"))
base_model = meta.get("base_model")
if base_model:
break
@@ -2354,7 +2359,7 @@ def scan_exported_models(
outputs_adapter_cfg = resolve_output_dir(run_dir.name) / "adapter_config.json"
try:
if outputs_adapter_cfg.exists():
- cfg = json.loads(outputs_adapter_cfg.read_text(encoding = "utf-8"))
+ cfg = json.loads(outputs_adapter_cfg.read_text(encoding = "utf-8-sig"))
base_model = cfg.get("base_model_name_or_path")
except Exception:
pass
@@ -2380,7 +2385,7 @@ def get_base_model_from_checkpoint(checkpoint_path: str) -> Optional[str]:
adapter_config_path = checkpoint_path_obj / "adapter_config.json"
if adapter_config_path.exists():
- with open(adapter_config_path, "r", encoding = "utf-8") as f:
+ with open(adapter_config_path, "r", encoding = "utf-8-sig") as f:
config = json.load(f)
base_model = config.get("base_model_name_or_path")
if base_model:
@@ -2389,7 +2394,7 @@ def get_base_model_from_checkpoint(checkpoint_path: str) -> Optional[str]:
config_path = checkpoint_path_obj / "config.json"
if config_path.exists():
- with open(config_path, "r", encoding = "utf-8") as f:
+ with open(config_path, "r", encoding = "utf-8-sig") as f:
config = json.load(f)
for key in ("model_name", "_name_or_path"):
base_model = config.get(key)
@@ -2445,7 +2450,7 @@ def get_base_model_from_lora(lora_path: str) -> Optional[str]:
# adapter_config.json first
adapter_config_path = lora_path_obj / "adapter_config.json"
if adapter_config_path.exists():
- with open(adapter_config_path, "r", encoding = "utf-8") as f:
+ with open(adapter_config_path, "r", encoding = "utf-8-sig") as f:
config = json.load(f)
base_model = config.get("base_model_name_or_path")
if base_model:
@@ -2535,7 +2540,7 @@ def get_base_model_from_lora_identifier(
last_exc = exc
continue
try:
- with open(cfg_path, "r", encoding = "utf-8") as f:
+ with open(cfg_path, "r", encoding = "utf-8-sig") as f:
base_model = json.load(f).get("base_model_name_or_path")
except Exception as exc:
logger.warning("Could not parse adapter_config.json for '%s': %s", identifier, exc)
@@ -2781,7 +2786,7 @@ class ModelConfig:
meta_path = gguf_dir / "export_metadata.json"
if meta_path.exists():
try:
- meta = json.loads(meta_path.read_text(encoding = "utf-8"))
+ meta = json.loads(meta_path.read_text(encoding = "utf-8-sig"))
base = meta.get("base_model")
if base and is_vision_model(base, hf_token = hf_token):
base_is_vision = True
@@ -2912,7 +2917,7 @@ class ModelConfig:
token = hf_token,
cache_dir = active_hf_hub_cache(),
)
- with open(config_path, "r", encoding = "utf-8") as f:
+ with open(config_path, "r", encoding = "utf-8-sig") as f:
adapter_config = json.load(f)
base_model = adapter_config.get("base_model_name_or_path")
if base_model:
diff --git a/studio/backend/utils/node_runtime.py b/studio/backend/utils/node_runtime.py
index fef2430708..697661a095 100644
--- a/studio/backend/utils/node_runtime.py
+++ b/studio/backend/utils/node_runtime.py
@@ -79,6 +79,8 @@ def _node_version_ok(executable: str) -> bool:
[executable, "-v"],
capture_output = True,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = _NODE_VERSION_PROBE_TIMEOUT_SECONDS,
**windows_hidden_subprocess_kwargs(),
)
diff --git a/studio/backend/utils/paths/storage_roots.py b/studio/backend/utils/paths/storage_roots.py
index ae1319d296..0b1398f6d2 100644
--- a/studio/backend/utils/paths/storage_roots.py
+++ b/studio/backend/utils/paths/storage_roots.py
@@ -212,7 +212,7 @@ def lmstudio_model_dirs() -> list[Path]:
settings_path = Path.home() / ".lmstudio" / "settings.json"
if settings_path.is_file():
try:
- with open(settings_path, encoding = "utf-8") as f:
+ with open(settings_path, encoding = "utf-8-sig") as f:
settings = json.load(f)
downloads = settings.get("downloadsFolder", "")
if downloads:
diff --git a/studio/backend/utils/prebuilt/update_flow.py b/studio/backend/utils/prebuilt/update_flow.py
index 74af0c18f9..69c1566fc3 100644
--- a/studio/backend/utils/prebuilt/update_flow.py
+++ b/studio/backend/utils/prebuilt/update_flow.py
@@ -24,6 +24,7 @@ from typing import Callable, Optional
import structlog
+from utils.child_stdio import utf8_child_env
from utils.process_lifetime import child_popen_kwargs
logger = structlog.get_logger(__name__)
@@ -159,6 +160,8 @@ def resolve_prebuilt_for_host(
cmd,
capture_output = True,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = 60,
)
out = (proc.stdout or "").strip()
@@ -303,7 +306,10 @@ def stream_installer(
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
- env = env,
+ encoding = "utf-8",
+ errors = "replace",
+ # Make the Python child emit the UTF-8 we decode above.
+ env = utf8_child_env(env),
**child_popen_kwargs(),
)
timed_out = threading.Event()
diff --git a/studio/backend/utils/security/consent.py b/studio/backend/utils/security/consent.py
index 6fee259139..9385270ee0 100644
--- a/studio/backend/utils/security/consent.py
+++ b/studio/backend/utils/security/consent.py
@@ -142,7 +142,7 @@ def _load_remote_code_configs(model_name: str, hf_token: Optional[str] = None) -
for name in _REMOTE_CODE_CONFIG_FILES:
p = root / name
if p.is_file():
- configs.append(json.loads(p.read_text(encoding = "utf-8")))
+ configs.append(json.loads(p.read_text(encoding = "utf-8-sig")))
return configs
from huggingface_hub import hf_hub_download
@@ -164,7 +164,7 @@ def _load_remote_code_configs(model_name: str, hf_token: Optional[str] = None) -
# Transient/auth failure is not "absent" -> fail closed to "unknown" so
# the caller scans (a tokenizer/processor-only auto_map must not slip by).
return None
- configs.append(json.loads(Path(p).read_text(encoding = "utf-8")))
+ configs.append(json.loads(Path(p).read_text(encoding = "utf-8-sig")))
# Every config was read or a genuine 404 -> an empty list is a definitive
# "no auto_map", not "unknown".
return configs
diff --git a/studio/backend/utils/security/file_security.py b/studio/backend/utils/security/file_security.py
index 7724406e8d..4588f32b90 100644
--- a/studio/backend/utils/security/file_security.py
+++ b/studio/backend/utils/security/file_security.py
@@ -199,7 +199,7 @@ def _indexed_shard_paths(
inconclusive = True # transient: an index that might exist could not be read
continue
try:
- weight_map = (json.loads(open(index_path, encoding = "utf-8").read()) or {}).get(
+ weight_map = (json.loads(open(index_path, encoding = "utf-8-sig").read()) or {}).get(
"weight_map"
) or {}
for shard in weight_map.values():
@@ -328,7 +328,7 @@ def _st_load_roots(snapshot: Path) -> list:
roots = [snapshot]
try:
import json
- modules = json.loads((snapshot / "modules.json").read_text(encoding = "utf-8"))
+ modules = json.loads((snapshot / "modules.json").read_text(encoding = "utf-8-sig"))
except (OSError, ValueError):
return roots # no / invalid modules.json -> snapshot root is the only load root
for module in modules or ():
@@ -355,7 +355,7 @@ def _indexed_pickle_shards(index_path: Path, root: Path, snapshot: Path) -> list
try:
# JSON is UTF-8 by spec; pin it so a non-ASCII index is not misdecoded (and needlessly
# blocked) under Windows' cp1252 default.
- parsed = json.loads(index_path.read_text(encoding = "utf-8"))
+ parsed = json.loads(index_path.read_text(encoding = "utf-8-sig"))
except (OSError, ValueError) as exc:
raise OSError(f"unreadable weight index: {index_path}") from exc
weight_map = parsed.get("weight_map") if isinstance(parsed, dict) else None
diff --git a/studio/backend/utils/security/remote_code_approvals.py b/studio/backend/utils/security/remote_code_approvals.py
index f1baac6924..d6076fd2b7 100644
--- a/studio/backend/utils/security/remote_code_approvals.py
+++ b/studio/backend/utils/security/remote_code_approvals.py
@@ -69,7 +69,7 @@ def approval_target_key(targets) -> str:
def _load() -> dict:
"""Parsed store, or an empty skeleton on any error (fail-safe = re-prompt)."""
try:
- with open(_store_path(), encoding = "utf-8") as f:
+ with open(_store_path(), encoding = "utf-8-sig") as f:
data = json.load(f)
# Validate the shape, not just the version: a hand-edited ``subjects`` that is not a
# dict (e.g. ``[]``) would otherwise crash lookup/record instead of failing safe.
diff --git a/studio/backend/utils/security/remote_code_scan.py b/studio/backend/utils/security/remote_code_scan.py
index d4d8003252..42f9d98efe 100644
--- a/studio/backend/utils/security/remote_code_scan.py
+++ b/studio/backend/utils/security/remote_code_scan.py
@@ -454,7 +454,7 @@ def repo_remote_code_files(model_name: str, hf_token: Optional[str] = None) -> d
p = root / name
if p.is_file():
try:
- ext_refs |= _auto_map_refs(json.loads(p.read_text(encoding = "utf-8")))
+ ext_refs |= _auto_map_refs(json.loads(p.read_text(encoding = "utf-8-sig")))
except Exception:
pass
if not _add_external_refs(files, ext_refs, hf_token, model_name):
@@ -483,7 +483,7 @@ def repo_remote_code_files(model_name: str, hf_token: Optional[str] = None) -> d
f"{model_name}: config {cfg_name} could not be fetched ({exc})"
) from exc
try:
- refs |= _auto_map_refs(json.loads(Path(cfg_path).read_text(encoding = "utf-8")))
+ refs |= _auto_map_refs(json.loads(Path(cfg_path).read_text(encoding = "utf-8-sig")))
except Exception:
pass
own_refs = {fn for repo, fn in refs if repo is None}
@@ -616,7 +616,7 @@ def external_auto_map_repos(model_name: str, hf_token: Optional[str] = None) ->
if not p.is_file():
continue
try:
- refs = _auto_map_refs(json.loads(p.read_text(encoding = "utf-8")))
+ refs = _auto_map_refs(json.loads(p.read_text(encoding = "utf-8-sig")))
except Exception:
continue
repos.update(repo for repo, _fn in refs if repo)
@@ -638,7 +638,7 @@ def external_auto_map_repos(model_name: str, hf_token: Optional[str] = None) ->
except Exception:
continue
try:
- refs = _auto_map_refs(json.loads(Path(cfg_path).read_text(encoding = "utf-8")))
+ refs = _auto_map_refs(json.loads(Path(cfg_path).read_text(encoding = "utf-8-sig")))
except Exception:
continue
repos.update(repo for repo, _fn in refs if repo)
diff --git a/studio/backend/utils/ssm_runtime.py b/studio/backend/utils/ssm_runtime.py
index ca7e2309f9..b864e78608 100644
--- a/studio/backend/utils/ssm_runtime.py
+++ b/studio/backend/utils/ssm_runtime.py
@@ -23,6 +23,7 @@ import threading
from typing import Any, Callable, Optional
from loggers import get_logger
+from utils.child_stdio import utf8_child_env
from utils.wheel_utils import (
direct_wheel_url,
install_wheel,
@@ -254,6 +255,12 @@ def _install_kernel(
"stdout": subprocess.PIPE,
"stderr": subprocess.STDOUT,
"text": True,
+ # pip and the compilers it drives write UTF-8 down this pipe; the Windows
+ # ANSI codepage would mojibake or raise over a fine install.
+ "encoding": "utf-8",
+ "errors": "replace",
+ # Make the Python child emit the UTF-8 we decode above.
+ "env": utf8_child_env(),
}
if is_hip:
run_kwargs["timeout"] = 1800 # ROCm builds can take 10-30 min
@@ -261,7 +268,8 @@ def _install_kernel(
if "--gcc-install-dir" not in existing:
gcc_dir = _hipcc_gcc_install_dir()
if gcc_dir:
- _env = os.environ.copy()
+ # Extends the UTF-8 env above rather than replacing it.
+ _env = dict(run_kwargs["env"])
_env["HIPCC_COMPILE_FLAGS_APPEND"] = (
f"{existing} --gcc-install-dir={gcc_dir}".strip()
)
diff --git a/studio/backend/utils/studio_version.py b/studio/backend/utils/studio_version.py
index 82ade74bba..cfaba36a81 100644
--- a/studio/backend/utils/studio_version.py
+++ b/studio/backend/utils/studio_version.py
@@ -60,6 +60,8 @@ def _exact_git_studio_tag(repo_root: Path) -> str | None:
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = _GIT_TIMEOUT_SECONDS,
)
except (OSError, subprocess.TimeoutExpired):
@@ -81,6 +83,8 @@ def _git_branch(repo_root: Path) -> str | None:
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = _GIT_TIMEOUT_SECONDS,
)
except (OSError, subprocess.TimeoutExpired):
diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py
index b0a2da0e66..3774409009 100644
--- a/studio/backend/utils/transformers_version.py
+++ b/studio/backend/utils/transformers_version.py
@@ -44,6 +44,7 @@ import time
from pathlib import Path
from utils.native_path_leases import child_env_without_native_path_secret
+from utils.child_stdio import utf8_child_env
from utils.hf_cache_settings import get_hf_cache_paths
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
@@ -420,7 +421,7 @@ def _resolve_base_model(model_name: str) -> str:
adapter_cfg_path = local_path / "adapter_config.json"
if _safe_is_file(adapter_cfg_path):
try:
- with open(adapter_cfg_path, encoding = "utf-8") as f:
+ with open(adapter_cfg_path, encoding = "utf-8-sig") as f:
cfg = json.load(f)
base = cfg.get("base_model_name_or_path")
if base:
@@ -437,7 +438,7 @@ def _resolve_base_model(model_name: str) -> str:
config_json_path = local_path / "config.json"
if _safe_is_file(config_json_path):
try:
- with open(config_json_path, encoding = "utf-8") as f:
+ with open(config_json_path, encoding = "utf-8-sig") as f:
cfg = json.load(f)
# Unsloth writes model_name, HF writes _name_or_path; skip a self-reference.
for _key in ("model_name", "_name_or_path"):
@@ -544,7 +545,7 @@ def _adapter_base_from_hf_cache(model_name: str) -> str | None:
)
for cfg_path in candidates:
if cfg_path.is_file():
- base = json.loads(cfg_path.read_text(encoding = "utf-8")).get(
+ base = json.loads(cfg_path.read_text(encoding = "utf-8-sig")).get(
"base_model_name_or_path"
)
return base or None
@@ -616,7 +617,7 @@ def _check_tokenizer_config_needs_v5(model_name: str, hf_token: str | None = Non
local_tc = local_path / "tokenizer_config.json"
if _safe_is_file(local_tc):
try:
- with open(local_tc, encoding = "utf-8") as f:
+ with open(local_tc, encoding = "utf-8-sig") as f:
data = json.load(f)
tokenizer_class = data.get("tokenizer_class", "")
result = tokenizer_class in _TRANSFORMERS_5_TOKENIZER_CLASSES
@@ -706,7 +707,7 @@ def _config_json_from_hf_cache(model_name: str) -> dict | None:
)
for cfg_path in candidates:
if cfg_path.is_file():
- with open(cfg_path, encoding = "utf-8") as f:
+ with open(cfg_path, encoding = "utf-8-sig") as f:
return json.load(f)
except Exception as exc:
logger.debug("HF cache config.json lookup failed for '%s': %s", model_name, exc)
@@ -731,7 +732,7 @@ def _load_config_json(model_name: str, hf_token: str | None = None) -> dict | No
local_cfg = Path(model_name) / "config.json"
if _safe_is_file(local_cfg):
try:
- with open(local_cfg, encoding = "utf-8") as f:
+ with open(local_cfg, encoding = "utf-8-sig") as f:
cfg = json.load(f)
_config_json_cache[cache_key] = cfg
return cfg
@@ -1271,9 +1272,10 @@ def _probe_autoconfig(target_dir: str, model_name: str, hf_token: str | None) ->
[sys.executable, "-c", _PROBE_CONFIG_SCRIPT, target_dir, model_name],
capture_output = True,
text = True,
+ encoding = "utf-8",
errors = "replace",
timeout = _PROBE_TIMEOUT_SECS,
- env = env,
+ env = utf8_child_env(env),
**_windows_hidden_subprocess_kwargs(),
)
except subprocess.TimeoutExpired:
@@ -1811,7 +1813,11 @@ def _install_to_dir(pkg: str, target_dir: str) -> bool:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
- env = get_hf_cache_paths().child_env(child_env_without_native_path_secret()),
+ encoding = "utf-8",
+ errors = "replace",
+ env = utf8_child_env(
+ get_hf_cache_paths().child_env(child_env_without_native_path_secret())
+ ),
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode == 0:
@@ -1834,7 +1840,9 @@ def _install_to_dir(pkg: str, target_dir: str) -> bool:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
- env = get_hf_cache_paths().child_env(child_env_without_native_path_secret()),
+ encoding = "utf-8",
+ errors = "replace",
+ env = utf8_child_env(get_hf_cache_paths().child_env(child_env_without_native_path_secret())),
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode != 0:
@@ -2079,7 +2087,7 @@ class SidecarSwapInProgress(RuntimeError):
def _read_swap_lock(path: Path) -> dict | None:
try:
- data = json.loads(path.read_text(encoding = "utf-8"))
+ data = json.loads(path.read_text(encoding = "utf-8-sig"))
return data if isinstance(data, dict) else {}
except FileNotFoundError:
return None
@@ -2120,7 +2128,7 @@ def try_begin_sidecar_swap(kind: str = "install") -> bool:
break
if fd is not None:
try:
- with os.fdopen(fd, "w") as f:
+ with os.fdopen(fd, "w", encoding = "utf-8") as f:
f.write(
json.dumps(
{"pid": os.getpid(), "at": time.time(), "token": token, "kind": kind}
@@ -2466,7 +2474,11 @@ def _ensure_venv_llmcompressor_exists() -> bool:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
- env = get_hf_cache_paths().child_env(child_env_without_native_path_secret()),
+ encoding = "utf-8",
+ errors = "replace",
+ env = utf8_child_env(
+ get_hf_cache_paths().child_env(child_env_without_native_path_secret())
+ ),
**_windows_hidden_subprocess_kwargs(),
)
last_out = result.stdout or ""
diff --git a/studio/backend/utils/update_status.py b/studio/backend/utils/update_status.py
index ad9dabcf36..d4b8ca1c16 100644
--- a/studio/backend/utils/update_status.py
+++ b/studio/backend/utils/update_status.py
@@ -30,6 +30,7 @@ PYPI_SUCCESS_TTL_SECONDS = 12 * 60 * 60
PYPI_FAILURE_TTL_SECONDS = 60 * 60
RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog"
DISABLE_ENV_VAR = "UNSLOTH_DISABLE_UPDATE_CHECK"
+FAKE_UPDATE_ENV_VAR = "UNSLOTH_STUDIO_FAKE_UPDATE"
LOCAL_INSTALL_SOURCES = {"editable", "local_path", "vcs", "local_repo"}
@@ -107,11 +108,32 @@ def get_studio_install_source_status(current_version: str) -> dict[str, Any]:
)
+def _is_version(value: str) -> bool:
+ try:
+ Version(value)
+ except InvalidVersion:
+ return False
+ return True
+
+
def get_studio_update_status(current_version: str) -> dict[str, Any]:
"""Return public, read-only update status for the web UI."""
install_source = detect_install_source()
+ disabled = os.environ.get(DISABLE_ENV_VAR) == "1"
- if os.environ.get(DISABLE_ENV_VAR) == "1":
+ # Dev-only: the popup is PyPI-install-only, so fake a version to review it
+ # from a checkout. The documented opt-out still wins.
+ forced_version = os.environ.get(FAKE_UPDATE_ENV_VAR, "").strip()
+ if forced_version and not disabled and _is_version(forced_version):
+ return _status_response(
+ current_version = current_version,
+ latest_version = forced_version,
+ install_source = "pypi",
+ update_available = True,
+ can_show_web_notification = True,
+ )
+
+ if disabled:
return _status_response(
current_version = current_version,
latest_version = None,
diff --git a/studio/backend/utils/utils.py b/studio/backend/utils/utils.py
index e4964b8d04..e830ea2700 100644
--- a/studio/backend/utils/utils.py
+++ b/studio/backend/utils/utils.py
@@ -114,6 +114,8 @@ def hf_cache_snapshot_dir(model_name: str) -> Optional[Path]:
snapshot = repo_dir / "snapshots" / commit
if snapshot.is_dir():
return snapshot
+ # UnicodeDecodeError is a ValueError, not an OSError: a torn refs
+ # file must keep meaning "not cached here", not fail the offline check.
except (OSError, UnicodeDecodeError):
continue
return None
diff --git a/studio/backend/utils/wheel_utils.py b/studio/backend/utils/wheel_utils.py
index 1b5926fd49..8ebdea3ac1 100644
--- a/studio/backend/utils/wheel_utils.py
+++ b/studio/backend/utils/wheel_utils.py
@@ -15,6 +15,7 @@ import urllib.request
from typing import Callable
from utils.native_path_leases import child_env_without_native_path_secret
+from utils.child_stdio import utf8_child_env
from utils.subprocess_compat import windows_hidden_subprocess_kwargs
_logger = logging.getLogger(__name__)
@@ -43,6 +44,8 @@ def has_blackwell_gpu() -> bool:
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = 10,
env = child_env_without_native_path_secret(),
)
@@ -102,8 +105,10 @@ def probe_torch_wheel_env(*, timeout: int | None = None) -> dict[str, str] | Non
stdout = subprocess.PIPE,
stderr = subprocess.PIPE,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
timeout = timeout,
- env = child_env_without_native_path_secret(),
+ env = utf8_child_env(child_env_without_native_path_secret()),
**windows_hidden_subprocess_kwargs(),
)
except subprocess.TimeoutExpired:
@@ -201,6 +206,8 @@ def install_wheel(
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
+ encoding = "utf-8",
+ errors = "replace",
env = child_env_without_native_path_secret(),
)
attempts.append(("uv", result))
@@ -213,7 +220,10 @@ def install_wheel(
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
- env = child_env_without_native_path_secret(),
+ encoding = "utf-8",
+ errors = "replace",
+ # Make the Python child emit the UTF-8 we decode above.
+ env = utf8_child_env(child_env_without_native_path_secret()),
)
attempts.append(("pip", result))
return attempts
diff --git a/studio/backend/utils/whisper_cpp_update.py b/studio/backend/utils/whisper_cpp_update.py
index cac37c25fc..45a0faf674 100644
--- a/studio/backend/utils/whisper_cpp_update.py
+++ b/studio/backend/utils/whisper_cpp_update.py
@@ -121,7 +121,14 @@ def _installed_whisper_version(binary: Optional[str]) -> Optional[str]:
if not binary:
return None
try:
- proc = subprocess.run([binary, "--version"], capture_output = True, text = True, timeout = 20)
+ proc = subprocess.run(
+ [binary, "--version"],
+ capture_output = True,
+ text = True,
+ encoding = "utf-8",
+ errors = "replace",
+ timeout = 20,
+ )
except Exception: # pragma: no cover - defensive
return None
m = re.search(r"v?(\d+\.\d+\.\d+)", (proc.stderr or "") + (proc.stdout or ""))
diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx
index d746ed952c..b076c8cf8d 100644
--- a/studio/frontend/src/app/provider.tsx
+++ b/studio/frontend/src/app/provider.tsx
@@ -214,7 +214,8 @@ function TauriUpdateLayer({
}
return (
-
+ // Capped like the browser stack: the download panel shares it, so both must fit.
+
{children}
- {/* One bottom-right stack so overlays never overlap; they stack with a
- gap, download panel anchored at the corner with banners above. */}
-
+ {/* One bottom-right stack so overlays never overlap: download panel at the
+ corner, banners above, each owning its width. */}
+ {/* Capped to the viewport, or a long download list plus expanded notes
+ pushes the top of the stack off screen. */}
+